Files
hapi/web/src/chat/normalizeAgent.ts
T

386 lines
13 KiB
TypeScript

import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types'
import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol'
import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages'
function normalizeToolResultPermissions(value: unknown): ToolResultPermission | undefined {
if (!isObject(value)) return undefined
const date = asNumber(value.date)
const result = value.result
if (date === null) return undefined
if (result !== 'approved' && result !== 'denied') return undefined
const mode = asString(value.mode) ?? undefined
const allowedTools = Array.isArray(value.allowedTools)
? value.allowedTools.filter((tool) => typeof tool === 'string')
: undefined
const decision = value.decision
const normalizedDecision = decision === 'approved' || decision === 'approved_for_session' || decision === 'denied' || decision === 'abort'
? decision
: undefined
return {
date,
result,
mode,
allowedTools,
decision: normalizedDecision
}
}
function normalizeAgentEvent(value: unknown): AgentEvent | null {
if (!isObject(value) || typeof value.type !== 'string') return null
return value as AgentEvent
}
function normalizeAssistantOutput(
messageId: string,
localId: string | null,
createdAt: number,
data: Record<string, unknown>,
meta?: unknown
): NormalizedMessage | null {
const uuid = asString(data.uuid) ?? messageId
const parentUUID = asString(data.parentUuid) ?? null
const isSidechain = Boolean(data.isSidechain)
const message = isObject(data.message) ? data.message : null
if (!message) return null
const modelContent = message.content
const blocks: NormalizedAgentContent[] = []
if (typeof modelContent === 'string') {
blocks.push({ type: 'text', text: modelContent, uuid, parentUUID })
} else if (Array.isArray(modelContent)) {
for (const block of modelContent) {
if (!isObject(block) || typeof block.type !== 'string') continue
if (block.type === 'text' && typeof block.text === 'string') {
blocks.push({ type: 'text', text: block.text, uuid, parentUUID })
continue
}
if (block.type === 'thinking' && typeof block.thinking === 'string') {
blocks.push({ type: 'reasoning', text: block.thinking, uuid, parentUUID })
continue
}
if (block.type === 'tool_use' && typeof block.id === 'string') {
const name = asString(block.name) ?? 'Tool'
const input = 'input' in block ? (block as Record<string, unknown>).input : undefined
const description = isObject(input) && typeof input.description === 'string' ? input.description : null
blocks.push({ type: 'tool-call', id: block.id, name, input, description, uuid, parentUUID })
}
}
}
const usage = isObject(message.usage) ? (message.usage as Record<string, unknown>) : null
const inputTokens = usage ? asNumber(usage.input_tokens) : null
const outputTokens = usage ? asNumber(usage.output_tokens) : null
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain,
content: blocks,
meta,
usage: inputTokens !== null && outputTokens !== null ? {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: asNumber(usage?.cache_creation_input_tokens) ?? undefined,
cache_read_input_tokens: asNumber(usage?.cache_read_input_tokens) ?? undefined,
service_tier: asString(usage?.service_tier) ?? undefined
} : undefined
}
}
function normalizeUserOutput(
messageId: string,
localId: string | null,
createdAt: number,
data: Record<string, unknown>,
meta?: unknown
): NormalizedMessage | null {
const uuid = asString(data.uuid) ?? messageId
const parentUUID = asString(data.parentUuid) ?? null
const isSidechain = Boolean(data.isSidechain)
const message = isObject(data.message) ? data.message : null
if (!message) return null
const messageContent = message.content
// Skip system-injected messages that were logged as type:'user' but are
// not text the human actually typed (task notifications, command caveats, etc.)
if (typeof messageContent === 'string') {
const trimmed = messageContent.trimStart()
if (
trimmed.startsWith('<task-notification>') ||
trimmed.startsWith('<system-reminder>')
) {
return null
}
}
if (isSidechain && typeof messageContent === 'string') {
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: true,
content: [{ type: 'sidechain', uuid, prompt: messageContent }]
}
}
if (typeof messageContent === 'string') {
return {
id: messageId,
localId,
createdAt,
role: 'user',
isSidechain: false,
content: { type: 'text', text: messageContent },
meta
}
}
const blocks: NormalizedAgentContent[] = []
if (Array.isArray(messageContent)) {
for (const block of messageContent) {
if (!isObject(block) || typeof block.type !== 'string') continue
if (block.type === 'text' && typeof block.text === 'string') {
blocks.push({ type: 'text', text: block.text, uuid, parentUUID })
continue
}
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
const isError = Boolean(block.is_error)
const rawContent = 'content' in block ? (block as Record<string, unknown>).content : undefined
const embeddedToolUseResult = 'toolUseResult' in data ? (data as Record<string, unknown>).toolUseResult : null
const permissions = normalizeToolResultPermissions(block.permissions)
blocks.push({
type: 'tool-result',
tool_use_id: block.tool_use_id,
content: embeddedToolUseResult ?? rawContent,
is_error: isError,
uuid,
parentUUID,
permissions
})
}
}
}
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain,
content: blocks,
meta
}
}
export function isSkippableAgentContent(content: unknown): boolean {
if (!isObject(content) || content.type !== 'output') return false
const data = isObject(content.data) ? content.data : null
if (!data) return false
if (Boolean(data.isMeta) || Boolean(data.isCompactSummary)) return true
return !isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype })
}
export function isCodexContent(content: unknown): boolean {
return isObject(content) && content.type === AGENT_MESSAGE_PAYLOAD_TYPE
}
export function normalizeAgentRecord(
messageId: string,
localId: string | null,
createdAt: number,
content: unknown,
meta?: unknown
): NormalizedMessage | null {
if (!isObject(content) || typeof content.type !== 'string') return null
if (content.type === 'output') {
const data = isObject(content.data) ? content.data : null
if (!data || typeof data.type !== 'string') return null
// Skip meta/compact-summary messages (parity with hapi-app)
if (data.isMeta) return null
if (data.isCompactSummary) return null
if (!isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype })) return null
if (data.type === 'assistant') {
return normalizeAssistantOutput(messageId, localId, createdAt, data, meta)
}
if (data.type === 'user') {
return normalizeUserOutput(messageId, localId, createdAt, data, meta)
}
if (data.type === 'summary' && typeof data.summary === 'string') {
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: false,
content: [{ type: 'summary', summary: data.summary }],
meta
}
}
if (data.type === 'system' && data.subtype === 'api_error') {
return {
id: messageId,
localId,
createdAt,
role: 'event',
content: {
type: 'api-error',
retryAttempt: asNumber(data.retryAttempt) ?? 0,
maxRetries: asNumber(data.maxRetries) ?? 0,
error: data.error
},
isSidechain: false,
meta
}
}
if (data.type === 'system' && data.subtype === 'turn_duration') {
return {
id: messageId,
localId,
createdAt,
role: 'event',
content: {
type: 'turn-duration',
durationMs: asNumber(data.durationMs) ?? 0
},
isSidechain: false,
meta
}
}
if (data.type === 'system' && data.subtype === 'microcompact_boundary') {
const metadata = isObject(data.microcompactMetadata) ? data.microcompactMetadata : null
return {
id: messageId,
localId,
createdAt,
role: 'event',
content: {
type: 'microcompact',
trigger: asString(metadata?.trigger) ?? 'auto',
preTokens: asNumber(metadata?.preTokens) ?? 0,
tokensSaved: asNumber(metadata?.tokensSaved) ?? 0
},
isSidechain: false,
meta
}
}
if (data.type === 'system' && data.subtype === 'compact_boundary') {
const metadata = isObject(data.compactMetadata) ? data.compactMetadata : null
return {
id: messageId,
localId,
createdAt,
role: 'event',
content: {
type: 'compact',
trigger: asString(metadata?.trigger) ?? 'auto',
preTokens: asNumber(metadata?.preTokens) ?? 0
},
isSidechain: false,
meta
}
}
return null
}
if (content.type === 'event') {
const event = normalizeAgentEvent(content.data)
if (!event) return null
return {
id: messageId,
localId,
createdAt,
role: 'event',
content: event,
isSidechain: false,
meta
}
}
if (content.type === AGENT_MESSAGE_PAYLOAD_TYPE) {
const data = isObject(content.data) ? content.data : null
if (!data || typeof data.type !== 'string') return null
if (data.type === 'message' && typeof data.message === 'string') {
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: false,
content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }],
meta
}
}
if (data.type === 'reasoning' && typeof data.message === 'string') {
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: false,
content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }],
meta
}
}
if (data.type === 'tool-call' && typeof data.callId === 'string') {
const uuid = asString(data.id) ?? messageId
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: false,
content: [{
type: 'tool-call',
id: data.callId,
name: asString(data.name) ?? 'unknown',
input: data.input,
description: null,
uuid,
parentUUID: null
}],
meta
}
}
if (data.type === 'tool-call-result' && typeof data.callId === 'string') {
const uuid = asString(data.id) ?? messageId
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: false,
content: [{
type: 'tool-result',
tool_use_id: data.callId,
content: data.output,
is_error: false,
uuid,
parentUUID: null
}],
meta
}
}
}
return null
}