Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/main/acp/runtime-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 12 additions & 4 deletions src/main/acp/runtime-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ContentBlock, SessionNotification, ToolCallContent } from '@agentc

import {
ACP_MESSAGE_IMAGE_EVENT_TEXT,
normalizeClaudeCodeRefusalText,
sanitizeAcpMessageImage,
type AcpRuntimeEvent
} from '../../shared/acp'
Expand Down Expand Up @@ -119,9 +120,15 @@ 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<AcpRuntimeEvent, 'text' | 'image'> => {
if (content.type !== 'image') return { text: contentToText(content) }
if (content.type !== 'image') {
const text = contentToText(content)
return {
text: normalizeClaudeCodeRefusal ? normalizeClaudeCodeRefusalText(text) : text
}
}

const image = sanitizeAcpMessageImage(content)

Expand Down Expand Up @@ -230,7 +237,8 @@ const projectToolDetailPayload = (update: ToolCallUpdate): Partial<AcpRuntimeEve
const toAcpRuntimeEvent = (
notification: SessionNotification,
id: string,
timestamp = Date.now()
timestamp = Date.now(),
normalizeClaudeCodeRefusal = false
): AcpRuntimeEvent => {
const { sessionId, update } = notification
const base = {
Expand All @@ -244,7 +252,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,
Expand Down
3 changes: 3 additions & 0 deletions src/main/acp/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/main/acp/session-update-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 7 additions & 1 deletion src/main/acp/session-update-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

type RuntimeProjectionRouting = Readonly<{
kind: 'runtime'
framework?: PermissionToolContext['framework']
appSessionId?: string
eventId: string
timestamp?: number
Expand Down Expand Up @@ -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([])
Expand Down
14 changes: 12 additions & 2 deletions src/main/tasks/task-runner.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/src/stores/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 9 additions & 2 deletions src/renderer/src/stores/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1471,6 +1472,12 @@ export const useSessionStore = create<SessionStore>((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()

Expand All @@ -1493,7 +1500,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
message.id === existingMessage.id
? {
...message,
content: `${message.content}${content}`,
content: mergedContent(message.content),
images: sanitizedImage
? sanitizeMessageImages([
...(message.images ?? []),
Expand All @@ -1513,7 +1520,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
const agentMessage: ChatMessage = {
id: messageId,
role: 'agent',
content,
content: mergedContent(),
status: 'streaming',
streamId,
responseToMessageId,
Expand Down
10 changes: 10 additions & 0 deletions src/shared/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcpPermissionScope, 'once'>

Expand Down
Loading