From 24495b4489051d1a41ab7b62e060484b16b3df33 Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 18:05:24 +0800 Subject: [PATCH 1/2] fix(acp): correct Claude refusal attribution --- src/main/acp/runtime-events.test.ts | 31 ++++++++++++++++ src/main/acp/runtime-events.ts | 23 +++++++++--- src/main/acp/runtime.ts | 3 ++ src/main/acp/session-update-projector.test.ts | 35 +++++++++++++++++++ src/main/acp/session-update-projector.ts | 8 ++++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/main/acp/runtime-events.test.ts b/src/main/acp/runtime-events.test.ts index a3a26f45d..1925c0c63 100644 --- a/src/main/acp/runtime-events.test.ts +++ b/src/main/acp/runtime-events.test.ts @@ -29,6 +29,37 @@ describe('ACP runtime event normalization', () => { }) }) + it('rewrites Claude Code policy attribution only for assistant messages', () => { + const text = + 'API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing the request in a new session or change your model.' + const notification: SessionNotification = { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text } + } + } + + expect(toAcpRuntimeEvent(notification, 'event-refusal', 1710000000000, true).text).toBe( + 'The selected model declined to complete this response under its safety policy. Try rephrasing the request in a new session or change your model.' + ) + expect(toAcpRuntimeEvent(notification, 'event-other-agent', 1710000000000).text).toBe(text) + expect( + toAcpRuntimeEvent( + { + sessionId: 'session-1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text } + } + }, + 'event-user-refusal', + 1710000000000, + true + ).text + ).toBe(text) + }) + it('preserves bounded assistant image chunks through the runtime fallback transport', () => { const notification: SessionNotification = { sessionId: 'session-1', diff --git a/src/main/acp/runtime-events.ts b/src/main/acp/runtime-events.ts index 5c59d1f5a..bc4a12dc8 100644 --- a/src/main/acp/runtime-events.ts +++ b/src/main/acp/runtime-events.ts @@ -6,6 +6,11 @@ import { type AcpRuntimeEvent } from '../../shared/acp' +const CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX = + 'API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup).' +const PROVIDER_NEUTRAL_REFUSAL_PREFIX = + 'The selected model declined to complete this response under its safety policy.' + // Bounds how much of a failed tool's result text reaches the log, so large or sensitive tool output // cannot flood it. Tuned to fit a typical error message (e.g. WebFetch's domain-safety preflight). const TOOL_FAILURE_TEXT_LIMIT = 300 @@ -119,9 +124,18 @@ const contentToText = (content: ContentBlock): string => { // Image notifications can carry megabytes of base64. Keep only bounded display data on the event; // the internal text sentinel lets the existing runtime projection forward image-only messages. const normalizeMessageContent = ( - content: ContentBlock + content: ContentBlock, + normalizeClaudeCodeRefusal = false ): Pick => { - if (content.type !== 'image') return { text: contentToText(content) } + if (content.type !== 'image') { + const text = contentToText(content) + return { + text: + normalizeClaudeCodeRefusal && text.startsWith(CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX) + ? text.replace(CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX, PROVIDER_NEUTRAL_REFUSAL_PREFIX) + : text + } + } const image = sanitizeAcpMessageImage(content) @@ -230,7 +244,8 @@ const projectToolDetailPayload = (update: ToolCallUpdate): Partial { const { sessionId, update } = notification const base = { @@ -244,7 +259,7 @@ const toAcpRuntimeEvent = ( // Group protocol update variants into the small set of event kinds the UI renders. switch (update.sessionUpdate) { case 'agent_message_chunk': { - const messageContent = normalizeMessageContent(update.content) + const messageContent = normalizeMessageContent(update.content, normalizeClaudeCodeRefusal) return { ...base, diff --git a/src/main/acp/runtime.ts b/src/main/acp/runtime.ts index a5e04f324..891233376 100644 --- a/src/main/acp/runtime.ts +++ b/src/main/acp/runtime.ts @@ -2804,6 +2804,9 @@ class AcpRuntime { this.applySessionUpdateEffects( this.sessionUpdateProjector.project(notification, { kind: 'runtime', + framework: + this.sessionRegistry.lookup(sessionId)?.aggregate.snapshot().frameworkId ?? + this.framework.id, appSessionId, eventId: this.nextEventId(), visible, diff --git a/src/main/acp/session-update-projector.test.ts b/src/main/acp/session-update-projector.test.ts index f50d798d9..89e13231f 100644 --- a/src/main/acp/session-update-projector.test.ts +++ b/src/main/acp/session-update-projector.test.ts @@ -75,6 +75,41 @@ describe('AcpSessionUpdateProjector', () => { expect(projector.project(notification, { ...routing, reconnectPending: true })).toEqual([]) }) + it('removes Claude Code policy attribution from visible refusal messages', () => { + const projector = new AcpSessionUpdateProjector() + const [context, refresh, visible] = projector.project( + { + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing.' + } + } + }, + { + kind: 'runtime', + framework: 'claude-code', + eventId: 'event-refusal', + visible: true, + reconnectPending: false, + mcpServerNames: [] + } + ) + + expect([context.kind, refresh.kind, visible.kind]).toEqual([ + 'context-observation', + 'context-refresh', + 'visible-event' + ]) + expect(visible).toMatchObject({ + event: { + text: 'The selected model declined to complete this response under its safety policy. Try rephrasing.' + } + }) + }) + it('projects hidden current-mode updates while a reconnect suppresses stale context effects', () => { const projector = new AcpSessionUpdateProjector() const notification: SessionNotification = { diff --git a/src/main/acp/session-update-projector.ts b/src/main/acp/session-update-projector.ts index 0e1654ba5..560f7d0c1 100644 --- a/src/main/acp/session-update-projector.ts +++ b/src/main/acp/session-update-projector.ts @@ -14,6 +14,7 @@ import { type RuntimeProjectionRouting = Readonly<{ kind: 'runtime' + framework?: PermissionToolContext['framework'] appSessionId?: string eventId: string timestamp?: number @@ -138,7 +139,12 @@ class AcpSessionUpdateProjector { } const projection = this.codexSkillActivity.projectWithContext( - toAcpRuntimeEvent(routed, routing.eventId, routing.timestamp) + toAcpRuntimeEvent( + routed, + routing.eventId, + routing.timestamp, + routing.framework === 'claude-code' + ) ) const event = deepFreeze(projection.event) if (event.contextUsage && routing.reconnectPending) return Object.freeze([]) From 960a274c0891ca22e217dfe5402d0a7de1c39ff4 Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 18:58:54 +0800 Subject: [PATCH 2/2] fix(acp): normalize streamed refusal messages --- src/main/acp/runtime-events.ts | 11 ++----- src/main/tasks/task-runner.ts | 14 +++++++-- src/renderer/src/stores/session-store.test.ts | 29 +++++++++++++++++++ src/renderer/src/stores/session-store.ts | 11 +++++-- src/shared/acp.ts | 10 +++++++ 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/main/acp/runtime-events.ts b/src/main/acp/runtime-events.ts index bc4a12dc8..a7989bd90 100644 --- a/src/main/acp/runtime-events.ts +++ b/src/main/acp/runtime-events.ts @@ -2,15 +2,11 @@ import type { ContentBlock, SessionNotification, ToolCallContent } from '@agentc import { ACP_MESSAGE_IMAGE_EVENT_TEXT, + normalizeClaudeCodeRefusalText, sanitizeAcpMessageImage, type AcpRuntimeEvent } from '../../shared/acp' -const CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX = - 'API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup).' -const PROVIDER_NEUTRAL_REFUSAL_PREFIX = - 'The selected model declined to complete this response under its safety policy.' - // Bounds how much of a failed tool's result text reaches the log, so large or sensitive tool output // cannot flood it. Tuned to fit a typical error message (e.g. WebFetch's domain-safety preflight). const TOOL_FAILURE_TEXT_LIMIT = 300 @@ -130,10 +126,7 @@ const normalizeMessageContent = ( if (content.type !== 'image') { const text = contentToText(content) return { - text: - normalizeClaudeCodeRefusal && text.startsWith(CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX) - ? text.replace(CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX, PROVIDER_NEUTRAL_REFUSAL_PREFIX) - : text + text: normalizeClaudeCodeRefusal ? normalizeClaudeCodeRefusalText(text) : text } } diff --git a/src/main/tasks/task-runner.ts b/src/main/tasks/task-runner.ts index a3bf3cd0d..85f2a6456 100644 --- a/src/main/tasks/task-runner.ts +++ b/src/main/tasks/task-runner.ts @@ -1,5 +1,9 @@ import type { AcpRuntimeEvent } from '../../shared/acp' -import { getAcpRuntimeEventImage, getAcpRuntimeEventText } from '../../shared/acp' +import { + getAcpRuntimeEventImage, + getAcpRuntimeEventText, + normalizeClaudeCodeRefusalText +} from '../../shared/acp' import type { ArtifactFile, FinalizeRunArtifactsRequest, @@ -553,7 +557,13 @@ class TaskRunner { (event) => event.kind === 'message' && event.role === 'assistant' ) const terminalStopEvent = [...events].reverse().find((event) => event.kind === 'stop') - const output = assistantEvents.map((event) => getAcpRuntimeEventText(event) ?? '').join('') + const streamedOutput = assistantEvents + .map((event) => getAcpRuntimeEventText(event) ?? '') + .join('') + const output = + session.agentFrameworkId === 'claude-code' + ? normalizeClaudeCodeRefusalText(streamedOutput) + : streamedOutput const images = assistantEvents .map((event) => { const image = getAcpRuntimeEventImage(event) diff --git a/src/renderer/src/stores/session-store.test.ts b/src/renderer/src/stores/session-store.test.ts index 07fa78f61..992267b88 100644 --- a/src/renderer/src/stores/session-store.test.ts +++ b/src/renderer/src/stores/session-store.test.ts @@ -381,6 +381,35 @@ describe('session store', () => { expect(session.activeRun).toBeUndefined() }) + it('normalizes a Claude refusal prefix after streamed chunks are merged', () => { + useSessionStore.getState().appendUserMessage({ + sessionId: 'transport-session-1', + content: 'Search the web', + agentFrameworkId: 'claude-code' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'assistant-message-1', + eventId: 'event-1', + content: 'API Error: Claude Code is unable to respond to this request, ' + }) + useSessionStore.getState().appendAgentMessageChunk({ + sessionId: 'transport-session-1', + streamId: 'assistant-message-1', + eventId: 'event-2', + content: + 'which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing.' + }) + + const session = useSessionStore.getState().sessions[0] + expect(session.messages[1]?.content).toBe( + 'The selected model declined to complete this response under its safety policy. Try rephrasing.' + ) + expect(toPersistedSession(session).messages[1]?.content).toBe( + 'The selected model declined to complete this response under its safety policy. Try rephrasing.' + ) + }) + it('attaches whole-turn usage only to the final agent message for the active prompt', () => { useSessionStore.getState().appendUserMessage({ sessionId: 'transport-session-1', diff --git a/src/renderer/src/stores/session-store.ts b/src/renderer/src/stores/session-store.ts index 434dc2d26..a207c7117 100644 --- a/src/renderer/src/stores/session-store.ts +++ b/src/renderer/src/stores/session-store.ts @@ -10,6 +10,7 @@ import type { ArtifactFile } from '../../../shared/artifacts' import { sanitizeActivityGroupTitle } from '../../../shared/activity-groups' import { MAX_ACP_SESSION_IMAGE_BYTES, + normalizeClaudeCodeRefusalText, sanitizeAcpMessageImage, type AcpContextUsage, type AcpMessageImage, @@ -1471,6 +1472,12 @@ export const useSessionStore = create((set, get) => ({ const existingMessage = session.messages.find( (message) => message.role === 'agent' && message.streamId === streamId ) + const mergedContent = (current = ''): string => { + const text = `${current}${content}` + return session.agentFrameworkId === 'claude-code' + ? normalizeClaudeCodeRefusalText(text) + : text + } const messageId = existingMessage?.id ?? createMessageId() const now = Date.now() @@ -1493,7 +1500,7 @@ export const useSessionStore = create((set, get) => ({ message.id === existingMessage.id ? { ...message, - content: `${message.content}${content}`, + content: mergedContent(message.content), images: sanitizedImage ? sanitizeMessageImages([ ...(message.images ?? []), @@ -1513,7 +1520,7 @@ export const useSessionStore = create((set, get) => ({ const agentMessage: ChatMessage = { id: messageId, role: 'agent', - content, + content: mergedContent(), status: 'streaming', streamId, responseToMessageId, diff --git a/src/shared/acp.ts b/src/shared/acp.ts index 3751a7cc6..7a50810fd 100644 --- a/src/shared/acp.ts +++ b/src/shared/acp.ts @@ -382,6 +382,16 @@ export const getAcpRuntimeEventText = (event: AcpRuntimeEvent): string | undefin ? undefined : event.text +const CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX = + 'API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup).' +const PROVIDER_NEUTRAL_REFUSAL_PREFIX = + 'The selected model declined to complete this response under its safety policy.' + +export const normalizeClaudeCodeRefusalText = (text: string): string => + text.startsWith(CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX) + ? `${PROVIDER_NEUTRAL_REFUSAL_PREFIX}${text.slice(CLAUDE_CODE_USAGE_POLICY_REFUSAL_PREFIX.length)}` + : text + export type AcpPermissionScope = 'once' | 'session' | 'project' | 'global' export type AcpPermissionGrantScope = Exclude