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

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