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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  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. import { it } from "./lib/effect"
  17. import { TestClock } from "effect/testing"
  18. const sessionID = Session.ID.make("ses_tool_event_test")
  19. const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  20. const capture = (providerMetadataKey = "anthropic", options?: { readonly interruptProgress?: boolean }) => {
  21. const published: Array<{ readonly type: string; readonly data: unknown }> = []
  22. const bus: Pick<Bus.Interface, "publish"> = {
  23. publish: (definition, data) => {
  24. const publish = Effect.sync(() => {
  25. const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload<typeof definition>
  26. published.push({
  27. type: definition.durable ? Bus.versionedType(definition.type, definition.durable.version) : 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("local failure metadata completes the progress snapshot", async () => {
  122. const { published, publisher } = capture()
  123. await Effect.runPromise(publisher.publish(call))
  124. await Effect.runPromise(publisher.progress(call.id, { phase: "running", provider: "old" }))
  125. await Effect.runPromise(
  126. publisher.failTool(call.id, { type: "tool.execution", message: "failed" }, { provider: "exa" }),
  127. )
  128. expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
  129. metadata: { phase: "running", provider: "exa" },
  130. })
  131. })
  132. test("failure snapshot retains canonical progress above the default byte limit", async () => {
  133. const { published, publisher } = capture("anthropic", { interruptProgress: true })
  134. await Effect.runPromise(publisher.publish(call))
  135. const detail = "x".repeat(60 * 1024)
  136. await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
  137. await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
  138. expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
  139. metadata: { detail },
  140. })
  141. })
  142. test("failure before progress omits partial output fields", async () => {
  143. const { published, publisher } = capture()
  144. await Effect.runPromise(publisher.publish(call))
  145. await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
  146. const failed = published.find((event) => event.type === "session.tool.failed.2")?.data
  147. expect(failed).not.toHaveProperty("content")
  148. expect(failed).not.toHaveProperty("metadata")
  149. })
  150. test("provider metadata is flattened using the route key", async () => {
  151. const { published, publisher } = capture()
  152. await Effect.runPromise(
  153. publisher.publish(
  154. LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }),
  155. ),
  156. )
  157. expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
  158. state: { signature: "signed" },
  159. })
  160. })
  161. test("reasoning state from start, empty delta, and end is merged", async () => {
  162. const { published, publisher } = capture()
  163. await Effect.runPromise(
  164. publisher.publish(
  165. LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }),
  166. ),
  167. )
  168. await Effect.runPromise(
  169. publisher.publish(
  170. LLMEvent.reasoningDelta({
  171. id: "reasoning",
  172. text: "",
  173. providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } },
  174. }),
  175. ),
  176. )
  177. await Effect.runPromise(
  178. publisher.publish(
  179. LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }),
  180. ),
  181. )
  182. expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
  183. state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
  184. })
  185. })
  186. it.effect("batches text deltas and flushes pending text before the terminal event", () =>
  187. Effect.gen(function* () {
  188. const { published, publisher } = capture()
  189. yield* Effect.forEach(
  190. [
  191. LLMEvent.textStart({ id: "text" }),
  192. LLMEvent.textDelta({ id: "text", text: "one" }),
  193. LLMEvent.textDelta({ id: "text", text: " two" }),
  194. LLMEvent.textDelta({ id: "text", text: " three" }),
  195. ],
  196. publisher.publish,
  197. { discard: true },
  198. )
  199. expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
  200. yield* TestClock.adjust("99 millis")
  201. expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
  202. yield* TestClock.adjust("1 millis")
  203. yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " four" }))
  204. expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
  205. { delta: "one two three four" },
  206. ])
  207. yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " five" }))
  208. yield* publisher.publish(LLMEvent.textEnd({ id: "text" }))
  209. expect(published.slice(-2).map((event) => event.type)).toEqual(["session.text.delta", "session.text.ended.1"])
  210. expect(published.at(-2)?.data).toMatchObject({ delta: " five" })
  211. }),
  212. )
  213. it.effect("batches reasoning deltas and flushes pending reasoning before the terminal event", () =>
  214. Effect.gen(function* () {
  215. const { published, publisher } = capture()
  216. yield* Effect.forEach(
  217. [
  218. LLMEvent.reasoningStart({ id: "reasoning" }),
  219. LLMEvent.reasoningDelta({ id: "reasoning", text: "one" }),
  220. LLMEvent.reasoningDelta({ id: "reasoning", text: " two" }),
  221. LLMEvent.reasoningDelta({ id: "reasoning", text: " three" }),
  222. LLMEvent.reasoningEnd({ id: "reasoning" }),
  223. ],
  224. publisher.publish,
  225. { discard: true },
  226. )
  227. expect(
  228. published.filter((event) => event.type === "session.reasoning.delta").map((event) => event.data),
  229. ).toMatchObject([{ delta: "one two three" }])
  230. expect(published.slice(-2).map((event) => event.type)).toEqual([
  231. "session.reasoning.delta",
  232. "session.reasoning.ended.1",
  233. ])
  234. }),
  235. )
  236. test("tool input deltas are accumulated without being published", async () => {
  237. const { published, publisher } = capture()
  238. await Effect.runPromise(
  239. Effect.forEach(
  240. [
  241. LLMEvent.toolInputStart({ id: "call", name: "read" }),
  242. LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
  243. LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file.txt"}' }),
  244. LLMEvent.toolInputEnd({ id: "call", name: "read" }),
  245. ],
  246. publisher.publish,
  247. { discard: true },
  248. ),
  249. )
  250. expect(published.some((event) => event.type === "session.tool.input.delta")).toBe(false)
  251. expect(published.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
  252. text: '{"path":"file.txt"}',
  253. })
  254. })
  255. test("provider-executed tool metadata is flattened using the route key", async () => {
  256. const { published, publisher } = capture("openai")
  257. await Effect.runPromise(
  258. publisher.publish(
  259. LLMEvent.toolCall({
  260. id: "hosted",
  261. name: "web_search",
  262. input: { query: "Effect" },
  263. providerExecuted: true,
  264. providerMetadata: { openai: { itemId: "call" } },
  265. }),
  266. ),
  267. )
  268. await Effect.runPromise(
  269. publisher.publish(
  270. LLMEvent.toolResult({
  271. id: "hosted",
  272. name: "web_search",
  273. result: { type: "json", value: { found: true } },
  274. providerExecuted: true,
  275. providerMetadata: { openai: { itemId: "result" } },
  276. }),
  277. ),
  278. )
  279. expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
  280. state: { itemId: "call" },
  281. })
  282. expect(published.find((event) => event.type === "session.tool.success.2")?.data).toMatchObject({
  283. resultState: { itemId: "result" },
  284. })
  285. })
  286. test("binary failure emits no success event", async () => {
  287. const { published, publisher } = capture()
  288. await Effect.runPromise(publisher.publish(call))
  289. await Effect.runPromise(publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }))
  290. expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
  291. expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
  292. })
  293. test("success event data can carry provider-executed result state", () => {
  294. const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
  295. sessionID,
  296. assistantMessageID: SessionMessage.ID.create(),
  297. id: "call-old",
  298. content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
  299. executed: true,
  300. resultState: {
  301. result: {
  302. type: "content",
  303. value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
  304. },
  305. },
  306. })
  307. expect(decoded.resultState).toMatchObject({ result: { type: "content" } })
  308. })
  309. test("step finish records settlement without publishing step ended", async () => {
  310. const { published, publisher } = capture()
  311. await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
  312. await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
  313. expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
  314. expect(publisher.record().finish).toMatchObject({ finish: "stop" })
  315. })
  316. test("content-filter finish retains failure evidence until step closeout", async () => {
  317. const { published, publisher } = capture()
  318. await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
  319. await Effect.runPromise(
  320. publisher.publish(
  321. LLMEvent.stepFinish({
  322. index: 0,
  323. reason: { normalized: "content-filter" },
  324. usage: {
  325. nonCachedInputTokens: 8,
  326. outputTokens: 3,
  327. reasoningTokens: 1,
  328. },
  329. }),
  330. ),
  331. )
  332. expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
  333. const settlement = publisher.record().finish
  334. expect(settlement).toMatchObject({
  335. finish: "content-filter",
  336. tokens: { input: 8, output: 2, reasoning: 1 },
  337. })
  338. if (!settlement) throw new Error("Expected content-filter settlement")
  339. await Effect.runPromise(
  340. publisher.publishStepFailure({
  341. cost: Money.USD.make(1.25),
  342. tokens: settlement.tokens,
  343. snapshot: Snapshot.ID.make("tree-end"),
  344. files: [RelativePath.make("src/changed.ts")],
  345. }),
  346. )
  347. expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
  348. expect(published.at(-1)?.data).toMatchObject({
  349. error: { type: "provider.content-filter", message: "Provider blocked the response" },
  350. cost: 1.25,
  351. tokens: { input: 8, output: 2, reasoning: 1 },
  352. snapshot: "tree-end",
  353. files: ["src/changed.ts"],
  354. })
  355. })
  356. test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
  357. const { published, publisher } = capture()
  358. await Effect.runPromise(
  359. Effect.forEach(
  360. [
  361. LLMEvent.stepStart({ index: 0 }),
  362. LLMEvent.textStart({ id: "text" }),
  363. LLMEvent.textDelta({ id: "text", text: "Partial" }),
  364. LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }),
  365. ],
  366. (event) => publisher.publish(event),
  367. { discard: true },
  368. ),
  369. )
  370. await Effect.runPromise(publisher.publishStepFailure())
  371. expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
  372. expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
  373. expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
  374. error: { type: "provider.content-filter" },
  375. })
  376. })