Files
hapi/cli/src/claude/utils/sdkToLogConverter.ts
T
weishu 7a487269ea refactor: replace hardcoded permission/model modes with centralized schema validation
Extract permission and model mode validation into reusable functions utilizing
@hapi/protocol schemas. Use isPermissionModeAllowedForFlavor and
isModelModeAllowedForFlavor to validate modes based on session flavor (claude/codex).
Replace inline type definitions with shared types from @hapi/protocol.
2026-01-04 22:07:03 +08:00

336 lines
11 KiB
TypeScript

/**
* Converter from SDK message types to log format (RawJSONLines)
* Transforms Claude SDK messages into the format expected by session logs
*/
import { randomUUID } from 'node:crypto'
import { execSync } from 'node:child_process'
import type {
SDKMessage,
SDKUserMessage,
SDKAssistantMessage,
SDKSystemMessage,
SDKResultMessage
} from '@/claude/sdk'
import type { RawJSONLines } from '@/claude/types'
import type { ClaudePermissionMode } from '@hapi/protocol/types'
/**
* Context for converting SDK messages to log format
*/
export interface ConversionContext {
sessionId: string
cwd: string
version?: string
gitBranch?: string
parentUuid?: string | null
}
type PermissionResponse = {
approved: boolean
mode?: ClaudePermissionMode
reason?: string
}
/**
* Get current git branch for the working directory
*/
function getGitBranch(cwd: string): string | undefined {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return branch || undefined
} catch {
return undefined
}
}
/**
* SDK to Log converter class
* Maintains state for parent-child relationships between messages
*/
export class SDKToLogConverter {
private lastUuid: string | null = null
private context: ConversionContext
private responses?: Map<string, PermissionResponse>
private sidechainLastUUID = new Map<string, string>();
constructor(
context: Omit<ConversionContext, 'parentUuid'>,
responses?: Map<string, PermissionResponse>
) {
this.context = {
...context,
gitBranch: context.gitBranch ?? getGitBranch(context.cwd),
version: context.version ?? process.env.npm_package_version ?? '0.0.0',
parentUuid: null
}
this.responses = responses
}
/**
* Update session ID (for when session changes during resume)
*/
updateSessionId(sessionId: string): void {
this.context.sessionId = sessionId
}
/**
* Reset parent chain (useful when starting new conversation)
*/
resetParentChain(): void {
this.lastUuid = null
this.context.parentUuid = null
}
/**
* Convert SDK message to log format
*/
convert(sdkMessage: SDKMessage): RawJSONLines | null {
const uuid = randomUUID()
const timestamp = new Date().toISOString()
let parentUuid = this.lastUuid;
let isSidechain = false;
if (sdkMessage.parent_tool_use_id) {
isSidechain = true;
parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null;
this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid);
}
const baseFields = {
parentUuid: parentUuid,
isSidechain: isSidechain,
userType: 'external' as const,
cwd: this.context.cwd,
sessionId: this.context.sessionId,
version: this.context.version,
gitBranch: this.context.gitBranch,
uuid,
timestamp
}
let logMessage: RawJSONLines | null = null
switch (sdkMessage.type) {
case 'user': {
const userMsg = sdkMessage as SDKUserMessage
logMessage = {
...baseFields,
type: 'user',
message: userMsg.message
}
// Check if this is a tool result and add mode if available
if (Array.isArray(userMsg.message.content)) {
for (const content of userMsg.message.content) {
if (content.type === 'tool_result' && content.tool_use_id && this.responses?.has(content.tool_use_id)) {
const response = this.responses.get(content.tool_use_id)
if (response?.mode) {
(logMessage as any).mode = response.mode
}
}
}
} else if (typeof userMsg.message.content === 'string') {
// Simple string content, no tool result
}
break
}
case 'assistant': {
const assistantMsg = sdkMessage as SDKAssistantMessage
logMessage = {
...baseFields,
type: 'assistant',
message: assistantMsg.message,
// Assistant messages often have additional fields
requestId: (assistantMsg as any).requestId
}
// if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) {
// for (const content of assistantMsg.message.content) {
// if (content.type === 'tool_use' && content.id) {
// this.sidechainLastUUID.set(content.id, uuid);
// }
// }
// }
break
}
case 'system': {
const systemMsg = sdkMessage as SDKSystemMessage
// System messages with subtype 'init' might update session ID
if (systemMsg.subtype === 'init' && systemMsg.session_id) {
this.updateSessionId(systemMsg.session_id)
}
// System messages are typically not sent to logs
// but we can convert them if needed
logMessage = {
...baseFields,
type: 'system',
subtype: systemMsg.subtype,
model: systemMsg.model,
tools: systemMsg.tools,
// Include all other fields
...(systemMsg as any)
}
break
}
case 'result': {
// Result messages are not converted to log messages
// They're SDK-specific messages that indicate session completion
// Not part of the actual conversation log
break
}
// Handle tool use results (often comes as user messages)
case 'tool_result': {
const toolMsg = sdkMessage as any
const baseLogMessage: any = {
...baseFields,
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolMsg.tool_use_id,
content: toolMsg.content
}]
},
toolUseResult: toolMsg.content
}
// Add mode if available from responses
if (toolMsg.tool_use_id && this.responses?.has(toolMsg.tool_use_id)) {
const response = this.responses.get(toolMsg.tool_use_id)
if (response?.mode) {
baseLogMessage.mode = response.mode
}
}
logMessage = baseLogMessage
break
}
default:
// Unknown message type - pass through with all fields
logMessage = {
...baseFields,
...sdkMessage,
type: (sdkMessage as any).type // Override type last to ensure it's set
} as any
}
// Update last UUID for parent tracking
if (logMessage && logMessage.type !== 'summary') {
this.lastUuid = uuid
}
return logMessage
}
/**
* Convert multiple SDK messages to log format
*/
convertMany(sdkMessages: SDKMessage[]): RawJSONLines[] {
return sdkMessages
.map(msg => this.convert(msg))
.filter((msg): msg is RawJSONLines => msg !== null)
}
/**
* Convert a simple string content to a sidechain user message
* Used for Task tool sub-agent prompts
*/
convertSidechainUserMessage(toolUseId: string, content: string): RawJSONLines {
const uuid = randomUUID()
const timestamp = new Date().toISOString()
this.sidechainLastUUID.set(toolUseId, uuid);
return {
parentUuid: null,
isSidechain: true,
userType: 'external' as const,
cwd: this.context.cwd,
sessionId: this.context.sessionId,
version: this.context.version,
gitBranch: this.context.gitBranch,
type: 'user',
message: {
role: 'user',
content: content
},
uuid,
timestamp
}
}
/**
* Generate an interrupted tool result message
* Used when a tool call is interrupted by the user
* @param toolUseId - The ID of the tool that was interrupted
* @param parentToolUseId - Optional parent tool ID if this is a sidechain tool
*/
generateInterruptedToolResult(toolUseId: string, parentToolUseId?: string | null): RawJSONLines {
const uuid = randomUUID()
const timestamp = new Date().toISOString()
const errorMessage = "[Request interrupted by user for tool use]"
// Determine if this is a sidechain and get parent UUID
let isSidechain = false
let parentUuid: string | null = this.lastUuid
if (parentToolUseId) {
isSidechain = true
// Look up the parent tool's UUID
parentUuid = this.sidechainLastUUID.get(parentToolUseId) ?? null
// Track this tool in the sidechain map
this.sidechainLastUUID.set(parentToolUseId, uuid)
}
const logMessage: RawJSONLines = {
type: 'user',
isSidechain: isSidechain,
uuid,
message: {
role: 'user',
content: [
{
type: 'tool_result',
content: errorMessage,
is_error: true,
tool_use_id: toolUseId
}
]
},
parentUuid: parentUuid,
userType: 'external' as const,
cwd: this.context.cwd,
sessionId: this.context.sessionId,
version: this.context.version,
gitBranch: this.context.gitBranch,
timestamp,
toolUseResult: `Error: ${errorMessage}`
} as any
// Update last UUID for tracking
this.lastUuid = uuid
return logMessage
}
}
/**
* Convenience function for one-off conversions
*/
export function convertSDKToLog(
sdkMessage: SDKMessage,
context: Omit<ConversionContext, 'parentUuid'>,
responses?: Map<string, PermissionResponse>
): RawJSONLines | null {
const converter = new SDKToLogConverter(context, responses)
return converter.convert(sdkMessage)
}