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

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