| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- import { expect, test } from "bun:test"
- import { Cause, Effect, Exit, Schema } from "effect"
- import { LLMEvent } from "@opencode-ai/ai"
- import { Money } from "@opencode-ai/schema/money"
- import { EventV2 } from "@opencode-ai/core/event"
- import { AgentV2 } from "@opencode-ai/core/agent"
- import { SessionEvent } from "@opencode-ai/core/session/event"
- import { SessionMessage } from "@opencode-ai/core/session/message"
- import { SessionV2 } from "@opencode-ai/core/session"
- import { ModelV2 } from "@opencode-ai/core/model"
- import { ProviderV2 } from "@opencode-ai/core/provider"
- import { RelativePath } from "@opencode-ai/core/schema"
- import { Snapshot } from "@opencode-ai/core/snapshot"
- import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
- const sessionID = SessionV2.ID.make("ses_tool_event_test")
- const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
- const capture = (providerMetadataKey = "anthropic", options?: { readonly interruptProgress?: boolean }) => {
- const published: Array<{ readonly type: string; readonly data: unknown }> = []
- const events: Pick<EventV2.Interface, "publish"> = {
- publish: (definition, data) => {
- const publish = Effect.sync(() => {
- const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
- published.push({
- type: definition.durable
- ? EventV2.versionedType(definition.type, definition.durable.version)
- : definition.type,
- data,
- })
- return event
- })
- return definition.type === SessionEvent.Tool.Progress.type && options?.interruptProgress
- ? publish.pipe(Effect.andThen(Effect.interrupt))
- : publish
- },
- }
- return {
- published,
- publisher: createLLMEventPublisher(events, {
- sessionID,
- agent: AgentV2.ID.make("build"),
- model: {
- id: ModelV2.ID.make("model"),
- providerID: ProviderV2.ID.opencode,
- },
- providerMetadataKey,
- }),
- }
- }
- const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
- const result = LLMEvent.toolResult({
- id: "call-image",
- name: "read",
- result: {
- type: "content",
- value: [
- { type: "text", text: "Image read successfully" },
- { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
- ],
- },
- output: {
- structured: { type: "media", mime: "image/png" },
- content: [
- { type: "text", text: "Image read successfully" },
- { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
- ],
- },
- })
- test("local tool success serializes media base64 once and reconstructs from structured content", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(publisher.publish(call))
- await Effect.runPromise(publisher.publish(result))
- const success = published.find((event) => event.type === "session.tool.success.1")
- expect(success).toBeDefined()
- const serialized = JSON.stringify(success)
- expect(serialized.split(base64)).toHaveLength(2)
- expect(success?.data).not.toHaveProperty("result")
- expect(success?.data).toMatchObject({
- content: [
- { type: "text", text: "Image read successfully" },
- { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" },
- ],
- })
- })
- test("provider-executed success retains its raw provider result", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
- await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
- const success = published.find((event) => event.type === "session.tool.success.1")
- expect(success?.data).toHaveProperty("result")
- })
- test("interrupted progress publication remains in the terminal failure snapshot", async () => {
- const { published, publisher } = capture("anthropic", { interruptProgress: true })
- await Effect.runPromise(publisher.publish(call))
- const exit = await Effect.runPromiseExit(
- publisher.progress(call.id, {
- structured: { phase: "visible" },
- content: [{ type: "text", text: "visible" }],
- }),
- )
- expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
- await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
- expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
- metadata: { phase: "visible" },
- content: [{ type: "text", text: "visible" }],
- })
- })
- test("failure before progress omits partial output fields", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(publisher.publish(call))
- await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
- const failed = published.find((event) => event.type === "session.tool.failed.1")?.data
- expect(failed).not.toHaveProperty("content")
- expect(failed).not.toHaveProperty("metadata")
- })
- test("provider metadata is flattened using the route key", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }),
- ),
- )
- expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
- state: { signature: "signed" },
- })
- })
- test("reasoning state from start, empty delta, and end is merged", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }),
- ),
- )
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.reasoningDelta({
- id: "reasoning",
- text: "",
- providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } },
- }),
- ),
- )
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }),
- ),
- )
- expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
- state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
- })
- })
- test("provider-executed tool metadata is flattened using the route key", async () => {
- const { published, publisher } = capture("openai")
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.toolCall({
- id: "hosted",
- name: "web_search",
- input: { query: "Effect" },
- providerExecuted: true,
- providerMetadata: { openai: { itemId: "call" } },
- }),
- ),
- )
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.toolResult({
- id: "hosted",
- name: "web_search",
- result: { type: "json", value: { found: true } },
- providerExecuted: true,
- providerMetadata: { openai: { itemId: "result" } },
- }),
- ),
- )
- expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
- state: { itemId: "call" },
- })
- expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
- resultState: { itemId: "result" },
- })
- })
- test("binary failure emits no success event", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(publisher.publish(call))
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.toolResult({
- id: call.id,
- name: call.name,
- result: { type: "error", value: "Cannot read binary file" },
- }),
- ),
- )
- expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
- expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
- })
- test("success event data can carry a provider-executed result", () => {
- const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
- sessionID,
- assistantMessageID: SessionMessage.ID.create(),
- callID: "call-old",
- structured: { type: "media", mime: "image/png" },
- content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
- result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
- executed: true,
- })
- expect(decoded.result).toMatchObject({ type: "content" })
- })
- test("step finish records settlement without publishing step ended", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
- await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
- expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
- expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
- })
- test("content-filter finish retains failure evidence until step closeout", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
- await Effect.runPromise(
- publisher.publish(
- LLMEvent.stepFinish({
- index: 0,
- reason: "content-filter",
- usage: {
- nonCachedInputTokens: 8,
- outputTokens: 3,
- reasoningTokens: 1,
- },
- }),
- ),
- )
- expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
- const settlement = publisher.stepSettlement()
- expect(settlement).toMatchObject({
- finish: "content-filter",
- tokens: { input: 8, output: 2, reasoning: 1 },
- })
- if (!settlement) throw new Error("Expected content-filter settlement")
- await Effect.runPromise(
- publisher.publishStepFailure({
- cost: Money.USD.make(1.25),
- tokens: settlement.tokens,
- snapshot: Snapshot.ID.make("tree-end"),
- files: [RelativePath.make("src/changed.ts")],
- }),
- )
- expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
- expect(published.at(-1)?.data).toMatchObject({
- error: { type: "provider.content-filter", message: "Provider blocked the response" },
- cost: 1.25,
- tokens: { input: 8, output: 2, reasoning: 1 },
- snapshot: "tree-end",
- files: ["src/changed.ts"],
- })
- })
- test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
- const { published, publisher } = capture()
- await Effect.runPromise(
- Effect.forEach(
- [
- LLMEvent.stepStart({ index: 0 }),
- LLMEvent.textStart({ id: "text" }),
- LLMEvent.textDelta({ id: "text", text: "Partial" }),
- LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
- ],
- (event) => publisher.publish(event),
- { discard: true },
- ),
- )
- await Effect.runPromise(publisher.publishStepFailure())
- expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
- expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
- expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
- error: { type: "provider.content-filter" },
- })
- })
|