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

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