/ utils / messages / mappers.ts
mappers.ts
  1  import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
  2  import { randomUUID, type UUID } from 'crypto'
  3  import { getSessionId } from 'src/bootstrap/state.js'
  4  import {
  5    LOCAL_COMMAND_STDERR_TAG,
  6    LOCAL_COMMAND_STDOUT_TAG,
  7  } from 'src/constants/xml.js'
  8  import type {
  9    SDKAssistantMessage,
 10    SDKCompactBoundaryMessage,
 11    SDKMessage,
 12    SDKRateLimitInfo,
 13  } from 'src/entrypoints/agentSdkTypes.js'
 14  import type { ClaudeAILimits } from 'src/services/claudeAiLimits.js'
 15  import { EXIT_PLAN_MODE_V2_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js'
 16  import type {
 17    AssistantMessage,
 18    CompactMetadata,
 19    Message,
 20  } from 'src/types/message.js'
 21  import type { DeepImmutable } from 'src/types/utils.js'
 22  import stripAnsi from 'strip-ansi'
 23  import { createAssistantMessage } from '../messages.js'
 24  import { getPlan } from '../plans.js'
 25  
 26  export function toInternalMessages(
 27    messages: readonly DeepImmutable<SDKMessage>[],
 28  ): Message[] {
 29    return messages.flatMap(message => {
 30      switch (message.type) {
 31        case 'assistant':
 32          return [
 33            {
 34              type: 'assistant',
 35              message: message.message,
 36              uuid: message.uuid,
 37              requestId: undefined,
 38              timestamp: new Date().toISOString(),
 39            } as Message,
 40          ]
 41        case 'user':
 42          return [
 43            {
 44              type: 'user',
 45              message: message.message,
 46              uuid: message.uuid ?? randomUUID(),
 47              timestamp: message.timestamp ?? new Date().toISOString(),
 48              isMeta: message.isSynthetic,
 49            } as Message,
 50          ]
 51        case 'system':
 52          // Handle compact boundary messages
 53          if (message.subtype === 'compact_boundary') {
 54            const compactMsg = message
 55            return [
 56              {
 57                type: 'system',
 58                content: 'Conversation compacted',
 59                level: 'info',
 60                subtype: 'compact_boundary',
 61                compactMetadata: fromSDKCompactMetadata(
 62                  compactMsg.compact_metadata,
 63                ),
 64                uuid: message.uuid,
 65                timestamp: new Date().toISOString(),
 66              },
 67            ]
 68          }
 69          return []
 70        default:
 71          return []
 72      }
 73    })
 74  }
 75  
 76  type SDKCompactMetadata = SDKCompactBoundaryMessage['compact_metadata']
 77  
 78  export function toSDKCompactMetadata(
 79    meta: CompactMetadata,
 80  ): SDKCompactMetadata {
 81    const seg = meta.preservedSegment
 82    return {
 83      trigger: meta.trigger,
 84      pre_tokens: meta.preTokens,
 85      ...(seg && {
 86        preserved_segment: {
 87          head_uuid: seg.headUuid,
 88          anchor_uuid: seg.anchorUuid,
 89          tail_uuid: seg.tailUuid,
 90        },
 91      }),
 92    }
 93  }
 94  
 95  /**
 96   * Shared SDK→internal compact_metadata converter.
 97   */
 98  export function fromSDKCompactMetadata(
 99    meta: SDKCompactMetadata,
100  ): CompactMetadata {
101    const seg = meta.preserved_segment
102    return {
103      trigger: meta.trigger,
104      preTokens: meta.pre_tokens,
105      ...(seg && {
106        preservedSegment: {
107          headUuid: seg.head_uuid,
108          anchorUuid: seg.anchor_uuid,
109          tailUuid: seg.tail_uuid,
110        },
111      }),
112    }
113  }
114  
115  export function toSDKMessages(messages: Message[]): SDKMessage[] {
116    return messages.flatMap((message): SDKMessage[] => {
117      switch (message.type) {
118        case 'assistant':
119          return [
120            {
121              type: 'assistant',
122              message: normalizeAssistantMessageForSDK(message),
123              session_id: getSessionId(),
124              parent_tool_use_id: null,
125              uuid: message.uuid,
126              error: message.error,
127            },
128          ]
129        case 'user':
130          return [
131            {
132              type: 'user',
133              message: message.message,
134              session_id: getSessionId(),
135              parent_tool_use_id: null,
136              uuid: message.uuid,
137              timestamp: message.timestamp,
138              isSynthetic: message.isMeta || message.isVisibleInTranscriptOnly,
139              // Structured tool output (not the string content sent to the
140              // model — the full Output object). Rides the protobuf catchall
141              // so web viewers can read things like BriefTool's file_uuid
142              // without it polluting model context.
143              ...(message.toolUseResult !== undefined
144                ? { tool_use_result: message.toolUseResult }
145                : {}),
146            },
147          ]
148        case 'system':
149          if (message.subtype === 'compact_boundary' && message.compactMetadata) {
150            return [
151              {
152                type: 'system',
153                subtype: 'compact_boundary' as const,
154                session_id: getSessionId(),
155                uuid: message.uuid,
156                compact_metadata: toSDKCompactMetadata(message.compactMetadata),
157              },
158            ]
159          }
160          // Only convert local_command messages that contain actual command
161          // output (stdout/stderr). The same subtype is also used for command
162          // input metadata (e.g. <command-name>...</command-name>) which must
163          // not leak to the RC web UI.
164          if (
165            message.subtype === 'local_command' &&
166            (message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
167              message.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`))
168          ) {
169            return [
170              localCommandOutputToSDKAssistantMessage(
171                message.content,
172                message.uuid,
173              ),
174            ]
175          }
176          return []
177        default:
178          return []
179      }
180    })
181  }
182  
183  /**
184   * Converts local command output (e.g. /voice, /cost) to a well-formed
185   * SDKAssistantMessage so downstream consumers (mobile apps, session-ingress
186   * v1alpha→v1beta converter) can parse it without schema changes.
187   *
188   * Emitted as assistant instead of the dedicated SDKLocalCommandOutputMessage
189   * because the system/local_command_output subtype is unknown to:
190   *   - mobile-apps Android SdkMessageTypes.kt (no local_command_output handler)
191   *   - api-go session-ingress convertSystemEvent (only init/compact_boundary)
192   * See: https://anthropic.sentry.io/issues/7266299248/ (Android)
193   *
194   * Strips ANSI (e.g. chalk.dim() in /cost) then unwraps the XML wrapper tags.
195   */
196  export function localCommandOutputToSDKAssistantMessage(
197    rawContent: string,
198    uuid: UUID,
199  ): SDKAssistantMessage {
200    const cleanContent = stripAnsi(rawContent)
201      .replace(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/, '$1')
202      .replace(/<local-command-stderr>([\s\S]*?)<\/local-command-stderr>/, '$1')
203      .trim()
204    // createAssistantMessage builds a complete APIAssistantMessage with id, type,
205    // model: SYNTHETIC_MODEL, role, stop_reason, usage — all fields required by
206    // downstream deserializers like Android's SdkAssistantMessage.
207    const synthetic = createAssistantMessage({ content: cleanContent })
208    return {
209      type: 'assistant',
210      message: synthetic.message,
211      parent_tool_use_id: null,
212      session_id: getSessionId(),
213      uuid,
214    }
215  }
216  
217  /**
218   * Maps internal ClaudeAILimits to the SDK-facing SDKRateLimitInfo type,
219   * stripping internal-only fields like unifiedRateLimitFallbackAvailable.
220   */
221  export function toSDKRateLimitInfo(
222    limits: ClaudeAILimits | undefined,
223  ): SDKRateLimitInfo | undefined {
224    if (!limits) {
225      return undefined
226    }
227    return {
228      status: limits.status,
229      ...(limits.resetsAt !== undefined && { resetsAt: limits.resetsAt }),
230      ...(limits.rateLimitType !== undefined && {
231        rateLimitType: limits.rateLimitType,
232      }),
233      ...(limits.utilization !== undefined && {
234        utilization: limits.utilization,
235      }),
236      ...(limits.overageStatus !== undefined && {
237        overageStatus: limits.overageStatus,
238      }),
239      ...(limits.overageResetsAt !== undefined && {
240        overageResetsAt: limits.overageResetsAt,
241      }),
242      ...(limits.overageDisabledReason !== undefined && {
243        overageDisabledReason: limits.overageDisabledReason,
244      }),
245      ...(limits.isUsingOverage !== undefined && {
246        isUsingOverage: limits.isUsingOverage,
247      }),
248      ...(limits.surpassedThreshold !== undefined && {
249        surpassedThreshold: limits.surpassedThreshold,
250      }),
251    }
252  }
253  
254  /**
255   * Normalizes tool inputs in assistant message content for SDK consumption.
256   * Specifically injects plan content into ExitPlanModeV2 tool inputs since
257   * the V2 tool reads plan from file instead of input, but SDK users expect
258   * tool_input.plan to exist.
259   */
260  function normalizeAssistantMessageForSDK(
261    message: AssistantMessage,
262  ): AssistantMessage['message'] {
263    const content = message.message.content
264    if (!Array.isArray(content)) {
265      return message.message
266    }
267  
268    const normalizedContent = content.map((block): BetaContentBlock => {
269      if (block.type !== 'tool_use') {
270        return block
271      }
272  
273      if (block.name === EXIT_PLAN_MODE_V2_TOOL_NAME) {
274        const plan = getPlan()
275        if (plan) {
276          return {
277            ...block,
278            input: { ...(block.input as Record<string, unknown>), plan },
279          }
280        }
281      }
282  
283      return block
284    })
285  
286    return {
287      ...message.message,
288      content: normalizedContent,
289    }
290  }