/ src / bridge / inboundMessages.ts
inboundMessages.ts
 1  import type {
 2    Base64ImageSource,
 3    ContentBlockParam,
 4    ImageBlockParam,
 5  } from '@anthropic-ai/sdk/resources/messages.mjs'
 6  import type { UUID } from 'crypto'
 7  import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
 8  import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
 9  
10  /**
11   * Process an inbound user message from the bridge, extracting content
12   * and UUID for enqueueing. Supports both string content and
13   * ContentBlockParam[] (e.g. messages containing images).
14   *
15   * Normalizes image blocks from bridge clients that may use camelCase
16   * `mediaType` instead of snake_case `media_type` (mobile-apps#5825).
17   *
18   * Returns the extracted fields, or undefined if the message should be
19   * skipped (non-user type, missing/empty content).
20   */
21  export function extractInboundMessageFields(
22    msg: SDKMessage,
23  ):
24    | { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
25    | undefined {
26    if (msg.type !== 'user') return undefined
27    const content = msg.message?.content
28    if (!content) return undefined
29    if (Array.isArray(content) && content.length === 0) return undefined
30  
31    const uuid =
32      'uuid' in msg && typeof msg.uuid === 'string'
33        ? (msg.uuid as UUID)
34        : undefined
35  
36    return {
37      content: Array.isArray(content) ? normalizeImageBlocks(content) : content,
38      uuid,
39    }
40  }
41  
42  /**
43   * Normalize image content blocks from bridge clients. iOS/web clients may
44   * send `mediaType` (camelCase) instead of `media_type` (snake_case), or
45   * omit the field entirely. Without normalization, the bad block poisons
46   * the session — every subsequent API call fails with
47   * "media_type: Field required".
48   *
49   * Fast-path scan returns the original array reference when no
50   * normalization is needed (zero allocation on the happy path).
51   */
52  export function normalizeImageBlocks(
53    blocks: Array<ContentBlockParam>,
54  ): Array<ContentBlockParam> {
55    if (!blocks.some(isMalformedBase64Image)) return blocks
56  
57    return blocks.map(block => {
58      if (!isMalformedBase64Image(block)) return block
59      const src = block.source as unknown as Record<string, unknown>
60      const mediaType =
61        typeof src.mediaType === 'string' && src.mediaType
62          ? src.mediaType
63          : detectImageFormatFromBase64(block.source.data)
64      return {
65        ...block,
66        source: {
67          type: 'base64' as const,
68          media_type: mediaType as Base64ImageSource['media_type'],
69          data: block.source.data,
70        },
71      }
72    })
73  }
74  
75  function isMalformedBase64Image(
76    block: ContentBlockParam,
77  ): block is ImageBlockParam & { source: Base64ImageSource } {
78    if (block.type !== 'image' || block.source?.type !== 'base64') return false
79    return !(block.source as unknown as Record<string, unknown>).media_type
80  }