/ remote / sdkMessageAdapter.ts
sdkMessageAdapter.ts
  1  import type {
  2    SDKAssistantMessage,
  3    SDKCompactBoundaryMessage,
  4    SDKMessage,
  5    SDKPartialAssistantMessage,
  6    SDKResultMessage,
  7    SDKStatusMessage,
  8    SDKSystemMessage,
  9    SDKToolProgressMessage,
 10  } from '../entrypoints/agentSdkTypes.js'
 11  import type {
 12    AssistantMessage,
 13    Message,
 14    StreamEvent,
 15    SystemMessage,
 16  } from '../types/message.js'
 17  import { logForDebugging } from '../utils/debug.js'
 18  import { fromSDKCompactMetadata } from '../utils/messages/mappers.js'
 19  import { createUserMessage } from '../utils/messages.js'
 20  
 21  /**
 22   * Converts SDKMessage from CCR to REPL Message types.
 23   *
 24   * The CCR backend sends SDK-format messages via WebSocket. The REPL expects
 25   * internal Message types for rendering. This adapter bridges the two.
 26   */
 27  
 28  /**
 29   * Convert an SDKAssistantMessage to an AssistantMessage
 30   */
 31  function convertAssistantMessage(msg: SDKAssistantMessage): AssistantMessage {
 32    return {
 33      type: 'assistant',
 34      message: msg.message,
 35      uuid: msg.uuid,
 36      requestId: undefined,
 37      timestamp: new Date().toISOString(),
 38      error: msg.error,
 39    }
 40  }
 41  
 42  /**
 43   * Convert an SDKPartialAssistantMessage (streaming) to a StreamEvent
 44   */
 45  function convertStreamEvent(msg: SDKPartialAssistantMessage): StreamEvent {
 46    return {
 47      type: 'stream_event',
 48      event: msg.event,
 49    }
 50  }
 51  
 52  /**
 53   * Convert an SDKResultMessage to a SystemMessage
 54   */
 55  function convertResultMessage(msg: SDKResultMessage): SystemMessage {
 56    const isError = msg.subtype !== 'success'
 57    const content = isError
 58      ? msg.errors?.join(', ') || 'Unknown error'
 59      : 'Session completed successfully'
 60  
 61    return {
 62      type: 'system',
 63      subtype: 'informational',
 64      content,
 65      level: isError ? 'warning' : 'info',
 66      uuid: msg.uuid,
 67      timestamp: new Date().toISOString(),
 68    }
 69  }
 70  
 71  /**
 72   * Convert an SDKSystemMessage (init) to a SystemMessage
 73   */
 74  function convertInitMessage(msg: SDKSystemMessage): SystemMessage {
 75    return {
 76      type: 'system',
 77      subtype: 'informational',
 78      content: `Remote session initialized (model: ${msg.model})`,
 79      level: 'info',
 80      uuid: msg.uuid,
 81      timestamp: new Date().toISOString(),
 82    }
 83  }
 84  
 85  /**
 86   * Convert an SDKStatusMessage to a SystemMessage
 87   */
 88  function convertStatusMessage(msg: SDKStatusMessage): SystemMessage | null {
 89    if (!msg.status) {
 90      return null
 91    }
 92  
 93    return {
 94      type: 'system',
 95      subtype: 'informational',
 96      content:
 97        msg.status === 'compacting'
 98          ? 'Compacting conversation…'
 99          : `Status: ${msg.status}`,
100      level: 'info',
101      uuid: msg.uuid,
102      timestamp: new Date().toISOString(),
103    }
104  }
105  
106  /**
107   * Convert an SDKToolProgressMessage to a SystemMessage.
108   * We use a system message instead of ProgressMessage since the Progress type
109   * is a complex union that requires tool-specific data we don't have from CCR.
110   */
111  function convertToolProgressMessage(
112    msg: SDKToolProgressMessage,
113  ): SystemMessage {
114    return {
115      type: 'system',
116      subtype: 'informational',
117      content: `Tool ${msg.tool_name} running for ${msg.elapsed_time_seconds}s…`,
118      level: 'info',
119      uuid: msg.uuid,
120      timestamp: new Date().toISOString(),
121      toolUseID: msg.tool_use_id,
122    }
123  }
124  
125  /**
126   * Convert an SDKCompactBoundaryMessage to a SystemMessage
127   */
128  function convertCompactBoundaryMessage(
129    msg: SDKCompactBoundaryMessage,
130  ): SystemMessage {
131    return {
132      type: 'system',
133      subtype: 'compact_boundary',
134      content: 'Conversation compacted',
135      level: 'info',
136      uuid: msg.uuid,
137      timestamp: new Date().toISOString(),
138      compactMetadata: fromSDKCompactMetadata(msg.compact_metadata),
139    }
140  }
141  
142  /**
143   * Result of converting an SDKMessage
144   */
145  export type ConvertedMessage =
146    | { type: 'message'; message: Message }
147    | { type: 'stream_event'; event: StreamEvent }
148    | { type: 'ignored' }
149  
150  type ConvertOptions = {
151    /** Convert user messages containing tool_result content blocks into UserMessages.
152     * Used by direct connect mode where tool results come from the remote server
153     * and need to be rendered locally. CCR mode ignores user messages since they
154     * are handled differently. */
155    convertToolResults?: boolean
156    /**
157     * Convert user text messages into UserMessages for display. Used when
158     * converting historical events where user-typed messages need to be shown.
159     * In live WS mode these are already added locally by the REPL so they're
160     * ignored by default.
161     */
162    convertUserTextMessages?: boolean
163  }
164  
165  /**
166   * Convert an SDKMessage to REPL message format
167   */
168  export function convertSDKMessage(
169    msg: SDKMessage,
170    opts?: ConvertOptions,
171  ): ConvertedMessage {
172    switch (msg.type) {
173      case 'assistant':
174        return { type: 'message', message: convertAssistantMessage(msg) }
175  
176      case 'user': {
177        const content = msg.message?.content
178        // Tool result messages from the remote server need to be converted so
179        // they render and collapse like local tool results. Detect via content
180        // shape (tool_result blocks) — parent_tool_use_id is NOT reliable: the
181        // agent-side normalizeMessage() hardcodes it to null for top-level
182        // tool results, so it can't distinguish tool results from prompt echoes.
183        const isToolResult =
184          Array.isArray(content) && content.some(b => b.type === 'tool_result')
185        if (opts?.convertToolResults && isToolResult) {
186          return {
187            type: 'message',
188            message: createUserMessage({
189              content,
190              toolUseResult: msg.tool_use_result,
191              uuid: msg.uuid,
192              timestamp: msg.timestamp,
193            }),
194          }
195        }
196        // When converting historical events, user-typed messages need to be
197        // rendered (they weren't added locally by the REPL). Skip tool_results
198        // here — already handled above.
199        if (opts?.convertUserTextMessages && !isToolResult) {
200          if (typeof content === 'string' || Array.isArray(content)) {
201            return {
202              type: 'message',
203              message: createUserMessage({
204                content,
205                toolUseResult: msg.tool_use_result,
206                uuid: msg.uuid,
207                timestamp: msg.timestamp,
208              }),
209            }
210          }
211        }
212        // User-typed messages (string content) are already added locally by REPL.
213        // In CCR mode, all user messages are ignored (tool results handled differently).
214        return { type: 'ignored' }
215      }
216  
217      case 'stream_event':
218        return { type: 'stream_event', event: convertStreamEvent(msg) }
219  
220      case 'result':
221        // Only show result messages for errors. Success results are noise
222        // in multi-turn sessions (isLoading=false is sufficient signal).
223        if (msg.subtype !== 'success') {
224          return { type: 'message', message: convertResultMessage(msg) }
225        }
226        return { type: 'ignored' }
227  
228      case 'system':
229        if (msg.subtype === 'init') {
230          return { type: 'message', message: convertInitMessage(msg) }
231        }
232        if (msg.subtype === 'status') {
233          const statusMsg = convertStatusMessage(msg)
234          return statusMsg
235            ? { type: 'message', message: statusMsg }
236            : { type: 'ignored' }
237        }
238        if (msg.subtype === 'compact_boundary') {
239          return {
240            type: 'message',
241            message: convertCompactBoundaryMessage(msg),
242          }
243        }
244        // hook_response and other subtypes
245        logForDebugging(
246          `[sdkMessageAdapter] Ignoring system message subtype: ${msg.subtype}`,
247        )
248        return { type: 'ignored' }
249  
250      case 'tool_progress':
251        return { type: 'message', message: convertToolProgressMessage(msg) }
252  
253      case 'auth_status':
254        // Auth status is handled separately, not converted to a display message
255        logForDebugging('[sdkMessageAdapter] Ignoring auth_status message')
256        return { type: 'ignored' }
257  
258      case 'tool_use_summary':
259        // Tool use summaries are SDK-only events, not displayed in REPL
260        logForDebugging('[sdkMessageAdapter] Ignoring tool_use_summary message')
261        return { type: 'ignored' }
262  
263      case 'rate_limit_event':
264        // Rate limit events are SDK-only events, not displayed in REPL
265        logForDebugging('[sdkMessageAdapter] Ignoring rate_limit_event message')
266        return { type: 'ignored' }
267  
268      default: {
269        // Gracefully ignore unknown message types. The backend may send new
270        // types before the client is updated; logging helps with debugging
271        // without crashing or losing the session.
272        logForDebugging(
273          `[sdkMessageAdapter] Unknown message type: ${(msg as { type: string }).type}`,
274        )
275        return { type: 'ignored' }
276      }
277    }
278  }
279  
280  /**
281   * Check if an SDKMessage indicates the session has ended
282   */
283  export function isSessionEndMessage(msg: SDKMessage): boolean {
284    return msg.type === 'result'
285  }
286  
287  /**
288   * Check if an SDKResultMessage indicates success
289   */
290  export function isSuccessResult(msg: SDKResultMessage): boolean {
291    return msg.subtype === 'success'
292  }
293  
294  /**
295   * Extract the result text from a successful SDKResultMessage
296   */
297  export function getResultText(msg: SDKResultMessage): string | null {
298    if (msg.subtype === 'success') {
299      return msg.result
300    }
301    return null
302  }