From e659e328ebe4ed4f488493c2ad79dc1590903c83 Mon Sep 17 00:00:00 2001 From: cyllas <121283+cyllas@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:45:21 -0300 Subject: [PATCH] fix(core): surface refusal category and explanation on content filter --- packages/core/src/v1/session.ts | 6 +++- packages/opencode/src/session/processor.ts | 20 ++++++++++- packages/opencode/src/session/prompt.ts | 7 +++- .../opencode/test/session/compaction.test.ts | 1 + .../test/session/content-filter.test.ts | 33 +++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/session/content-filter.test.ts diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index b4ce31e5f09a..b1fef5818ddf 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -65,4 +65,8 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) -export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String }) +export const ContentFilterError = NamedError.create("ContentFilterError", { + message: Schema.String, + category: Schema.optional(Schema.String), + explanation: Schema.optional(Schema.String), +}) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..cbc2f969f639 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -24,13 +24,14 @@ import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" -import { Usage, type LLMEvent } from "@opencode-ai/llm" +import { Usage, type LLMEvent, type ProviderMetadata } from "@opencode-ai/llm" const DOOM_LOOP_THRESHOLD = 3 export type Result = "compact" | "stop" | "continue" export interface Handle { readonly message: SessionV1.Assistant + readonly contentFilter: { category?: string; explanation?: string } | undefined readonly updateToolCall: ( toolCallID: string, update: (part: SessionV1.ToolPart) => SessionV1.ToolPart, @@ -72,10 +73,20 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: SessionV1.TextPart | undefined reasoningMap: Record + contentFilter: { category?: string; explanation?: string } | undefined } type StreamEvent = LLMEvent +export function readContentFilter(metadata: ProviderMetadata | undefined) { + const details = metadata?.["anthropic"]?.["stopDetails"] + if (!isRecord(details)) return undefined + const category = typeof details["category"] === "string" ? details["category"] : undefined + const explanation = typeof details["explanation"] === "string" ? details["explanation"] : undefined + if (!category && !explanation) return undefined + return { ...(category ? { category } : {}), ...(explanation ? { explanation } : {}) } +} + export class Service extends Context.Service()("@opencode/SessionProcessor") {} const layer = Layer.effect( @@ -111,6 +122,7 @@ const layer = Layer.effect( needsCompaction: false, currentText: undefined, reasoningMap: {}, + contentFilter: undefined, } let aborted = false @@ -441,6 +453,7 @@ const layer = Layer.effect( metadata: value.providerMetadata, }) ctx.assistantMessage.finish = value.reason + if (value.reason === "content-filter") ctx.contentFilter = readContentFilter(value.providerMetadata) ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ @@ -532,6 +545,8 @@ const layer = Layer.effect( return case "finish": + if (!ctx.contentFilter && value.reason === "content-filter") + ctx.contentFilter = readContentFilter(value.providerMetadata) return } }) @@ -686,6 +701,9 @@ const layer = Layer.effect( get message() { return ctx.assistantMessage }, + get contentFilter() { + return ctx.contentFilter + }, updateToolCall, completeToolCall, process, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..c2c36a0cce9b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1299,8 +1299,13 @@ const layer = Layer.effect( // output at all — previously the session went idle silently — or // partial text that was cut off by the provider's filter. if (handle.message.finish === "content-filter") { + const filter = handle.contentFilter handle.message.error = new SessionV1.ContentFilterError({ - message: "The response was blocked by the provider's content filter", + message: filter?.category + ? `Response refused by provider (${filter.category})${filter.explanation ? `: ${filter.explanation}` : ""}` + : "The response was blocked by the provider's content filter", + category: filter?.category, + explanation: filter?.explanation, }).toObject() yield* sessions.updateMessage(handle.message) yield* events.publish(Session.Event.Error, { sessionID, error: handle.message.error }) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 4a4210cf08bc..801c7bf44522 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -202,6 +202,7 @@ function fake( get message() { return msg }, + contentFilter: undefined, updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)), diff --git a/packages/opencode/test/session/content-filter.test.ts b/packages/opencode/test/session/content-filter.test.ts new file mode 100644 index 000000000000..2033f23cbf02 --- /dev/null +++ b/packages/opencode/test/session/content-filter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { SessionProcessor } from "../../src/session/processor" + +describe("readContentFilter", () => { + test("extracts category and explanation from anthropic stopDetails", () => { + const metadata = { + anthropic: { + stopDetails: { type: "refusal", category: "reasoning_extraction", explanation: "declined per policy" }, + }, + } + expect(SessionProcessor.readContentFilter(metadata)).toEqual({ + category: "reasoning_extraction", + explanation: "declined per policy", + }) + }) + + test("keeps only the fields that are present", () => { + const metadata = { anthropic: { stopDetails: { type: "refusal", category: "cyber" } } } + expect(SessionProcessor.readContentFilter(metadata)).toEqual({ category: "cyber" }) + }) + + test("returns undefined when the refusal has no named category or explanation", () => { + const metadata = { anthropic: { stopDetails: { type: "refusal", category: null, explanation: null } } } + expect(SessionProcessor.readContentFilter(metadata)).toBeUndefined() + }) + + test("returns undefined without anthropic stopDetails", () => { + expect(SessionProcessor.readContentFilter(undefined)).toBeUndefined() + expect(SessionProcessor.readContentFilter({})).toBeUndefined() + expect(SessionProcessor.readContentFilter({ anthropic: {} })).toBeUndefined() + expect(SessionProcessor.readContentFilter({ openai: { stopDetails: { category: "cyber" } } })).toBeUndefined() + }) +})