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

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