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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { expect, test } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { LLMEvent } from "@opencode-ai/llm"
  4. import { EventV2 } from "@opencode-ai/core/event"
  5. import { SessionEvent } from "@opencode-ai/core/session/event"
  6. import { SessionMessage } from "@opencode-ai/core/session/message"
  7. import { SessionV2 } from "@opencode-ai/core/session"
  8. import { ModelV2 } from "@opencode-ai/core/model"
  9. import { ProviderV2 } from "@opencode-ai/core/provider"
  10. import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
  11. const sessionID = SessionV2.ID.make("ses_tool_event_test")
  12. const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  13. const capture = () => {
  14. const published: Array<{ readonly type: string; readonly data: unknown }> = []
  15. const events = EventV2.Service.of({
  16. publish: (definition, data) =>
  17. Effect.sync(() => {
  18. const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
  19. published.push({
  20. type: definition.durable
  21. ? EventV2.versionedType(definition.type, definition.durable.version)
  22. : definition.type,
  23. data,
  24. })
  25. return event
  26. }),
  27. subscribe: () => Stream.empty,
  28. all: () => Stream.empty,
  29. durable: () => Stream.empty,
  30. listen: () => Effect.succeed(Effect.void),
  31. project: () => Effect.void,
  32. replay: () => Effect.void,
  33. replayAll: () => Effect.succeed(undefined),
  34. remove: () => Effect.void,
  35. claim: () => Effect.void,
  36. })
  37. return {
  38. published,
  39. publisher: createLLMEventPublisher(events, {
  40. sessionID,
  41. agent: "build",
  42. model: {
  43. id: ModelV2.ID.make("model"),
  44. providerID: ProviderV2.ID.make("provider"),
  45. },
  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.next.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 compatibility 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.next.tool.success.1")
  89. expect(success?.data).toHaveProperty("result")
  90. })
  91. test("binary failure emits no success event", async () => {
  92. const { published, publisher } = capture()
  93. await Effect.runPromise(publisher.publish(call))
  94. await Effect.runPromise(
  95. publisher.publish(
  96. LLMEvent.toolResult({
  97. id: call.id,
  98. name: call.name,
  99. result: { type: "error", value: "Cannot read binary file" },
  100. }),
  101. ),
  102. )
  103. expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false)
  104. expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true)
  105. })
  106. test("old success event data containing result still decodes", () => {
  107. const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
  108. sessionID,
  109. timestamp: Date.now(),
  110. assistantMessageID: SessionMessage.ID.create(),
  111. callID: "call-old",
  112. structured: { type: "media", mime: "image/png" },
  113. content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
  114. result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
  115. provider: { executed: false },
  116. })
  117. expect(decoded.result).toMatchObject({ type: "content" })
  118. })
  119. test("step finish records settlement without publishing step ended", async () => {
  120. const { published, publisher } = capture()
  121. await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
  122. await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
  123. expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
  124. expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
  125. })