session-runner-tool-events.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { LLMEvent } from "@opencode-ai/ai"
  4. import { Money } from "@opencode-ai/schema/money"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { AgentV2 } from "@opencode-ai/core/agent"
  7. import { SessionEvent } from "@opencode-ai/core/session/event"
  8. import { SessionMessage } from "@opencode-ai/core/session/message"
  9. import { SessionV2 } from "@opencode-ai/core/session"
  10. import { ModelV2 } from "@opencode-ai/core/model"
  11. import { ProviderV2 } from "@opencode-ai/core/provider"
  12. import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
  13. const sessionID = SessionV2.ID.make("ses_tool_event_test")
  14. const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  15. const capture = (providerMetadataKey = "anthropic") => {
  16. const published: Array<{ readonly type: string; readonly data: unknown }> = []
  17. const events: Pick<EventV2.Interface, "publish"> = {
  18. publish: (definition, data) =>
  19. Effect.sync(() => {
  20. const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
  21. published.push({
  22. type: definition.durable
  23. ? EventV2.versionedType(definition.type, definition.durable.version)
  24. : definition.type,
  25. data,
  26. })
  27. return event
  28. }),
  29. }
  30. return {
  31. published,
  32. publisher: createLLMEventPublisher(events, {
  33. sessionID,
  34. agent: AgentV2.ID.make("build"),
  35. model: {
  36. id: ModelV2.ID.make("model"),
  37. providerID: ProviderV2.ID.opencode,
  38. },
  39. providerMetadataKey,
  40. }),
  41. }
  42. }
  43. const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
  44. const result = LLMEvent.toolResult({
  45. id: "call-image",
  46. name: "read",
  47. result: {
  48. type: "content",
  49. value: [
  50. { type: "text", text: "Image read successfully" },
  51. { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
  52. ],
  53. },
  54. output: {
  55. structured: { type: "media", mime: "image/png" },
  56. content: [
  57. { type: "text", text: "Image read successfully" },
  58. { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
  59. ],
  60. },
  61. })
  62. test("local tool success serializes media base64 once and reconstructs from structured content", async () => {
  63. const { published, publisher } = capture()
  64. await Effect.runPromise(publisher.publish(call))
  65. await Effect.runPromise(publisher.publish(result))
  66. const success = published.find((event) => event.type === "session.tool.success.1")
  67. expect(success).toBeDefined()
  68. const serialized = JSON.stringify(success)
  69. expect(serialized.split(base64)).toHaveLength(2)
  70. expect(success?.data).not.toHaveProperty("result")
  71. expect(success?.data).toMatchObject({
  72. content: [
  73. { type: "text", text: "Image read successfully" },
  74. { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" },
  75. ],
  76. })
  77. })
  78. test("provider-executed success retains its raw provider result", async () => {
  79. const { published, publisher } = capture()
  80. await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
  81. await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
  82. const success = published.find((event) => event.type === "session.tool.success.1")
  83. expect(success?.data).toHaveProperty("result")
  84. })
  85. test("provider metadata is flattened using the route key", async () => {
  86. const { published, publisher } = capture()
  87. await Effect.runPromise(
  88. publisher.publish(
  89. LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }),
  90. ),
  91. )
  92. expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
  93. state: { signature: "signed" },
  94. })
  95. })
  96. test("reasoning state from start, empty delta, and end is merged", async () => {
  97. const { published, publisher } = capture()
  98. await Effect.runPromise(
  99. publisher.publish(
  100. LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }),
  101. ),
  102. )
  103. await Effect.runPromise(
  104. publisher.publish(
  105. LLMEvent.reasoningDelta({
  106. id: "reasoning",
  107. text: "",
  108. providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } },
  109. }),
  110. ),
  111. )
  112. await Effect.runPromise(
  113. publisher.publish(
  114. LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }),
  115. ),
  116. )
  117. expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
  118. state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
  119. })
  120. })
  121. test("provider-executed tool metadata is flattened using the route key", async () => {
  122. const { published, publisher } = capture("openai")
  123. await Effect.runPromise(
  124. publisher.publish(
  125. LLMEvent.toolCall({
  126. id: "hosted",
  127. name: "web_search",
  128. input: { query: "Effect" },
  129. providerExecuted: true,
  130. providerMetadata: { openai: { itemId: "call" } },
  131. }),
  132. ),
  133. )
  134. await Effect.runPromise(
  135. publisher.publish(
  136. LLMEvent.toolResult({
  137. id: "hosted",
  138. name: "web_search",
  139. result: { type: "json", value: { found: true } },
  140. providerExecuted: true,
  141. providerMetadata: { openai: { itemId: "result" } },
  142. }),
  143. ),
  144. )
  145. expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
  146. state: { itemId: "call" },
  147. })
  148. expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
  149. resultState: { itemId: "result" },
  150. })
  151. })
  152. test("binary failure emits no success event", async () => {
  153. const { published, publisher } = capture()
  154. await Effect.runPromise(publisher.publish(call))
  155. await Effect.runPromise(
  156. publisher.publish(
  157. LLMEvent.toolResult({
  158. id: call.id,
  159. name: call.name,
  160. result: { type: "error", value: "Cannot read binary file" },
  161. }),
  162. ),
  163. )
  164. expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
  165. expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
  166. })
  167. test("success event data can carry a provider-executed result", () => {
  168. const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
  169. sessionID,
  170. assistantMessageID: SessionMessage.ID.create(),
  171. callID: "call-old",
  172. structured: { type: "media", mime: "image/png" },
  173. content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
  174. result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
  175. executed: true,
  176. })
  177. expect(decoded.result).toMatchObject({ type: "content" })
  178. })
  179. test("step finish records settlement without publishing step ended", async () => {
  180. const { published, publisher } = capture()
  181. await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
  182. await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
  183. expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
  184. expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
  185. })
  186. test("content-filter finish retains failure evidence until step closeout", async () => {
  187. const { published, publisher } = capture()
  188. await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
  189. await Effect.runPromise(
  190. publisher.publish(
  191. LLMEvent.stepFinish({
  192. index: 0,
  193. reason: "content-filter",
  194. usage: {
  195. nonCachedInputTokens: 8,
  196. outputTokens: 3,
  197. reasoningTokens: 1,
  198. },
  199. }),
  200. ),
  201. )
  202. expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
  203. const settlement = publisher.stepSettlement()
  204. expect(settlement).toMatchObject({
  205. finish: "content-filter",
  206. tokens: { input: 8, output: 2, reasoning: 1 },
  207. })
  208. if (!settlement) throw new Error("Expected content-filter settlement")
  209. await Effect.runPromise(
  210. publisher.publishStepFailure({
  211. cost: Money.USD.make(1.25),
  212. tokens: settlement.tokens,
  213. }),
  214. )
  215. expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
  216. expect(published.at(-1)?.data).toMatchObject({
  217. error: { type: "provider.content-filter", message: "Provider blocked the response" },
  218. cost: 1.25,
  219. tokens: { input: 8, output: 2, reasoning: 1 },
  220. })
  221. })
  222. test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
  223. const { published, publisher } = capture()
  224. await Effect.runPromise(
  225. Effect.forEach(
  226. [
  227. LLMEvent.stepStart({ index: 0 }),
  228. LLMEvent.textStart({ id: "text" }),
  229. LLMEvent.textDelta({ id: "text", text: "Partial" }),
  230. LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
  231. ],
  232. (event) => publisher.publish(event),
  233. { discard: true },
  234. ),
  235. )
  236. await Effect.runPromise(publisher.publishStepFailure())
  237. expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
  238. expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
  239. expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
  240. error: { type: "provider.content-filter" },
  241. })
  242. })