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

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