303 lines
8.8 KiB
TypeScript
303 lines
8.8 KiB
TypeScript
import type {
|
|
SDKAssistantMessage,
|
|
SDKCompactBoundaryMessage,
|
|
SDKMessage,
|
|
SDKPartialAssistantMessage,
|
|
SDKResultMessage,
|
|
SDKStatusMessage,
|
|
SDKSystemMessage,
|
|
SDKToolProgressMessage,
|
|
} from '../entrypoints/agentSdkTypes.ts'
|
|
import type {
|
|
AssistantMessage,
|
|
Message,
|
|
StreamEvent,
|
|
SystemMessage,
|
|
} from '../types/message.js'
|
|
import { logForDebugging } from '../utils/debug.js'
|
|
import { fromSDKCompactMetadata } from '../utils/messages/mappers.js'
|
|
import { createUserMessage } from '../utils/messages.js'
|
|
|
|
/**
|
|
* Converts SDKMessage from CCR to REPL Message types.
|
|
*
|
|
* The CCR backend sends SDK-format messages via WebSocket. The REPL expects
|
|
* internal Message types for rendering. This adapter bridges the two.
|
|
*/
|
|
|
|
/**
|
|
* Convert an SDKAssistantMessage to an AssistantMessage
|
|
*/
|
|
function convertAssistantMessage(msg: SDKAssistantMessage): AssistantMessage {
|
|
return {
|
|
type: 'assistant',
|
|
message: msg.message,
|
|
uuid: msg.uuid,
|
|
requestId: undefined,
|
|
timestamp: new Date().toISOString(),
|
|
error: msg.error,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKPartialAssistantMessage (streaming) to a StreamEvent
|
|
*/
|
|
function convertStreamEvent(msg: SDKPartialAssistantMessage): StreamEvent {
|
|
return {
|
|
type: 'stream_event',
|
|
event: msg.event,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKResultMessage to a SystemMessage
|
|
*/
|
|
function convertResultMessage(msg: SDKResultMessage): SystemMessage {
|
|
const isError = msg.subtype !== 'success'
|
|
const content = isError
|
|
? msg.errors?.join(', ') || 'Unknown error'
|
|
: 'Session completed successfully'
|
|
|
|
return {
|
|
type: 'system',
|
|
subtype: 'informational',
|
|
content,
|
|
level: isError ? 'warning' : 'info',
|
|
uuid: msg.uuid,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKSystemMessage (init) to a SystemMessage
|
|
*/
|
|
function convertInitMessage(msg: SDKSystemMessage): SystemMessage {
|
|
return {
|
|
type: 'system',
|
|
subtype: 'informational',
|
|
content: `Remote session initialized (model: ${msg.model})`,
|
|
level: 'info',
|
|
uuid: msg.uuid,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKStatusMessage to a SystemMessage
|
|
*/
|
|
function convertStatusMessage(msg: SDKStatusMessage): SystemMessage | null {
|
|
if (!msg.status) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
type: 'system',
|
|
subtype: 'informational',
|
|
content:
|
|
msg.status === 'compacting'
|
|
? 'Compacting conversation…'
|
|
: `Status: ${msg.status}`,
|
|
level: 'info',
|
|
uuid: msg.uuid,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKToolProgressMessage to a SystemMessage.
|
|
* We use a system message instead of ProgressMessage since the Progress type
|
|
* is a complex union that requires tool-specific data we don't have from CCR.
|
|
*/
|
|
function convertToolProgressMessage(
|
|
msg: SDKToolProgressMessage,
|
|
): SystemMessage {
|
|
return {
|
|
type: 'system',
|
|
subtype: 'informational',
|
|
content: `Tool ${msg.tool_name} running for ${msg.elapsed_time_seconds}s…`,
|
|
level: 'info',
|
|
uuid: msg.uuid,
|
|
timestamp: new Date().toISOString(),
|
|
toolUseID: msg.tool_use_id,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKCompactBoundaryMessage to a SystemMessage
|
|
*/
|
|
function convertCompactBoundaryMessage(
|
|
msg: SDKCompactBoundaryMessage,
|
|
): SystemMessage {
|
|
return {
|
|
type: 'system',
|
|
subtype: 'compact_boundary',
|
|
content: 'Conversation compacted',
|
|
level: 'info',
|
|
uuid: msg.uuid,
|
|
timestamp: new Date().toISOString(),
|
|
compactMetadata: fromSDKCompactMetadata(msg.compact_metadata),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Result of converting an SDKMessage
|
|
*/
|
|
export type ConvertedMessage =
|
|
| { type: 'message'; message: Message }
|
|
| { type: 'stream_event'; event: StreamEvent }
|
|
| { type: 'ignored' }
|
|
|
|
type ConvertOptions = {
|
|
/** Convert user messages containing tool_result content blocks into UserMessages.
|
|
* Used by direct connect mode where tool results come from the remote server
|
|
* and need to be rendered locally. CCR mode ignores user messages since they
|
|
* are handled differently. */
|
|
convertToolResults?: boolean
|
|
/**
|
|
* Convert user text messages into UserMessages for display. Used when
|
|
* converting historical events where user-typed messages need to be shown.
|
|
* In live WS mode these are already added locally by the REPL so they're
|
|
* ignored by default.
|
|
*/
|
|
convertUserTextMessages?: boolean
|
|
}
|
|
|
|
/**
|
|
* Convert an SDKMessage to REPL message format
|
|
*/
|
|
export function convertSDKMessage(
|
|
msg: SDKMessage,
|
|
opts?: ConvertOptions,
|
|
): ConvertedMessage {
|
|
switch (msg.type) {
|
|
case 'assistant':
|
|
return { type: 'message', message: convertAssistantMessage(msg) }
|
|
|
|
case 'user': {
|
|
const content = msg.message?.content
|
|
// Tool result messages from the remote server need to be converted so
|
|
// they render and collapse like local tool results. Detect via content
|
|
// shape (tool_result blocks) — parent_tool_use_id is NOT reliable: the
|
|
// agent-side normalizeMessage() hardcodes it to null for top-level
|
|
// tool results, so it can't distinguish tool results from prompt echoes.
|
|
const isToolResult =
|
|
Array.isArray(content) && content.some(b => b.type === 'tool_result')
|
|
if (opts?.convertToolResults && isToolResult) {
|
|
return {
|
|
type: 'message',
|
|
message: createUserMessage({
|
|
content,
|
|
toolUseResult: msg.tool_use_result,
|
|
uuid: msg.uuid,
|
|
timestamp: msg.timestamp,
|
|
}),
|
|
}
|
|
}
|
|
// When converting historical events, user-typed messages need to be
|
|
// rendered (they weren't added locally by the REPL). Skip tool_results
|
|
// here — already handled above.
|
|
if (opts?.convertUserTextMessages && !isToolResult) {
|
|
if (typeof content === 'string' || Array.isArray(content)) {
|
|
return {
|
|
type: 'message',
|
|
message: createUserMessage({
|
|
content,
|
|
toolUseResult: msg.tool_use_result,
|
|
uuid: msg.uuid,
|
|
timestamp: msg.timestamp,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
// User-typed messages (string content) are already added locally by REPL.
|
|
// In CCR mode, all user messages are ignored (tool results handled differently).
|
|
return { type: 'ignored' }
|
|
}
|
|
|
|
case 'stream_event':
|
|
return { type: 'stream_event', event: convertStreamEvent(msg) }
|
|
|
|
case 'result':
|
|
// Only show result messages for errors. Success results are noise
|
|
// in multi-turn sessions (isLoading=false is sufficient signal).
|
|
if (msg.subtype !== 'success') {
|
|
return { type: 'message', message: convertResultMessage(msg) }
|
|
}
|
|
return { type: 'ignored' }
|
|
|
|
case 'system':
|
|
if (msg.subtype === 'init') {
|
|
return { type: 'message', message: convertInitMessage(msg) }
|
|
}
|
|
if (msg.subtype === 'status') {
|
|
const statusMsg = convertStatusMessage(msg)
|
|
return statusMsg
|
|
? { type: 'message', message: statusMsg }
|
|
: { type: 'ignored' }
|
|
}
|
|
if (msg.subtype === 'compact_boundary') {
|
|
return {
|
|
type: 'message',
|
|
message: convertCompactBoundaryMessage(msg),
|
|
}
|
|
}
|
|
// hook_response and other subtypes
|
|
logForDebugging(
|
|
`[sdkMessageAdapter] Ignoring system message subtype: ${msg.subtype}`,
|
|
)
|
|
return { type: 'ignored' }
|
|
|
|
case 'tool_progress':
|
|
return { type: 'message', message: convertToolProgressMessage(msg) }
|
|
|
|
case 'auth_status':
|
|
// Auth status is handled separately, not converted to a display message
|
|
logForDebugging('[sdkMessageAdapter] Ignoring auth_status message')
|
|
return { type: 'ignored' }
|
|
|
|
case 'tool_use_summary':
|
|
// Tool use summaries are SDK-only events, not displayed in REPL
|
|
logForDebugging('[sdkMessageAdapter] Ignoring tool_use_summary message')
|
|
return { type: 'ignored' }
|
|
|
|
case 'rate_limit_event':
|
|
// Rate limit events are SDK-only events, not displayed in REPL
|
|
logForDebugging('[sdkMessageAdapter] Ignoring rate_limit_event message')
|
|
return { type: 'ignored' }
|
|
|
|
default: {
|
|
// Gracefully ignore unknown message types. The backend may send new
|
|
// types before the client is updated; logging helps with debugging
|
|
// without crashing or losing the session.
|
|
logForDebugging(
|
|
`[sdkMessageAdapter] Unknown message type: ${(msg as { type: string }).type}`,
|
|
)
|
|
return { type: 'ignored' }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an SDKMessage indicates the session has ended
|
|
*/
|
|
export function isSessionEndMessage(msg: SDKMessage): boolean {
|
|
return msg.type === 'result'
|
|
}
|
|
|
|
/**
|
|
* Check if an SDKResultMessage indicates success
|
|
*/
|
|
export function isSuccessResult(msg: SDKResultMessage): boolean {
|
|
return msg.subtype === 'success'
|
|
}
|
|
|
|
/**
|
|
* Extract the result text from a successful SDKResultMessage
|
|
*/
|
|
export function getResultText(msg: SDKResultMessage): string | null {
|
|
if (msg.subtype === 'success') {
|
|
return msg.result
|
|
}
|
|
return null
|
|
}
|