Files
hapi/shared/src/messages.ts
T
Junmo KimandGitHub 659913c0c9 fix(shared): hide tool_progress heartbeat events from chat delivery (#1094)
서브에이전트(sidechain)가 오래 걸리는 도구를 실행할 때 SDK가 주기적으로
내보내는 tool_progress heartbeat 이벤트가 isClaudeChatVisibleMessage()의
기본 통과 분기를 거쳐 raw JSON 그대로 채팅에 노출되던 문제를 고친다.
rate_limit_event 필터링(#423)과 동일한 패턴으로 타입 전체를 deny한다.
2026-07-29 20:18:09 +08:00

187 lines
6.6 KiB
TypeScript

import { isObject } from './utils'
type RoleWrappedRecord = {
role: string
content: unknown
meta?: unknown
}
const VISIBLE_CLAUDE_SYSTEM_SUBTYPES = new Set([
'api_error',
'turn_duration',
'microcompact_boundary',
'compact_boundary',
// Auto-generated recap Claude Code's local TUI writes to the transcript on
// window blur/focus (5min+ idle). Only observed via the local launcher's
// transcript scan — SDK/remote mode never emits it. Chat-visible here also
// means CLI-forwarded, web-rendered, and included in session export
// (parity with turn_duration / compact_boundary).
'away_summary'
])
export function isRoleWrappedRecord(value: unknown): value is RoleWrappedRecord {
if (!isObject(value)) return false
return typeof value.role === 'string' && 'content' in value
}
export function unwrapRoleWrappedRecordEnvelope(value: unknown): RoleWrappedRecord | null {
if (isRoleWrappedRecord(value)) return value
if (!isObject(value)) return null
const direct = value.message
if (isRoleWrappedRecord(direct)) return direct
const data = value.data
if (isObject(data) && isRoleWrappedRecord(data.message)) return data.message as RoleWrappedRecord
const payload = value.payload
if (isObject(payload) && isRoleWrappedRecord(payload.message)) return payload.message as RoleWrappedRecord
return null
}
export function isClaudeChatVisibleSystemSubtype(subtype: unknown): subtype is string {
return typeof subtype === 'string' && VISIBLE_CLAUDE_SYSTEM_SUBTYPES.has(subtype)
}
export function isClaudeChatVisibleMessage(message: { type: unknown; subtype?: unknown }): boolean {
if (message.type === 'rate_limit_event') {
return false
}
if (message.type === 'tool_progress') {
return false
}
if (message.type !== 'system') {
return true
}
return isClaudeChatVisibleSystemSubtype(message.subtype)
}
export function isRedundantGoalStatusMessageText(value: unknown): boolean {
if (typeof value !== 'string') return false
const message = value.trim()
return message === 'Goal cleared'
|| /^Goal (active|paused|complete|limited by budget)(?:$|\s+·\s+)/.test(message)
}
export function isRedundantGoalStatusEventContent(value: unknown): boolean {
const record = unwrapRoleWrappedRecordEnvelope(value)
if (record?.role !== 'agent') return false
const eventContent = record.content
if (!isObject(eventContent) || eventContent.type !== 'event') return false
const data = isObject(eventContent.data) ? eventContent.data : null
if (!data || data.type !== 'message') return false
return isRedundantGoalStatusMessageText(data.message)
}
/**
* Best-effort plain-text extraction from a stored agent message's `content`.
*
* Two structural shapes are common in this fork:
*
* 1. `codex` flavor: content.type = 'codex', content.data.type = 'message'
* -> assistant text at `content.data.message` (string).
*
* 2. `output` flavor (Claude SDK passthrough): content.type = 'output',
* content.data.type = 'assistant' -> text at
* `content.data.message.content[i].text` (array of `{type:'text', text}`).
*
* Returns `null` when the content does not look like assistant *text*
* (tool calls, tool results, reasoning, token counts, etc.) so callers can
* skip those messages and fall back to the previous one.
*/
export function extractAssistantPlainText(content: unknown): string | null {
if (!isObject(content)) return null
if (content.type === 'codex') {
const data = isObject(content.data) ? content.data : null
if (!data || data.type !== 'message') return null
return typeof data.message === 'string' && data.message.length > 0
? data.message
: null
}
if (content.type === 'output') {
const data = isObject(content.data) ? content.data : null
if (!data || data.type !== 'assistant') return null
const message = isObject(data.message) ? data.message : null
const blocks = Array.isArray(message?.content) ? message.content : null
if (!blocks) return null
const textParts: string[] = []
for (const block of blocks) {
if (!isObject(block)) continue
if (block.type === 'text' && typeof block.text === 'string') {
textParts.push(block.text)
}
}
if (textParts.length === 0) return null
return textParts.join('\n')
}
return null
}
const NOTIFY_SUMMARY_PREFIX = 'AGENT_NOTIFY_SUMMARY '
export type NotifySummary = {
version?: number
agent?: string
project?: string
status?: string
action?: string
summary?: string
}
/**
* Look for an `AGENT_NOTIFY_SUMMARY {...json...}` line as the **last
* non-empty line** of an agent's plain-text message.
*
* Strict end-anchor: anything below the JSON line (even whitespace) is
* fine, but if the agent wrote prose AFTER the line we treat it as
* non-compliant and return null. This also makes false positives from
* `AGENT_NOTIFY_SUMMARY` quoted inside an earlier paragraph harmless,
* because such a quote is never the last line.
*
* Returns the parsed object on success, `null` on any deviation. The
* shape is intentionally loose - we only trust `summary`, `action`, and
* `status` for notification rendering, but the full object is forwarded
* onto the meta-event bus when Phase 2 lands.
*/
export function extractNotifySummary(text: unknown): NotifySummary | null {
if (typeof text !== 'string' || text.length === 0) return null
const lines = text.split('\n')
let lastIdx = lines.length - 1
while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1
if (lastIdx < 0) return null
const lastLine = lines[lastIdx].trim()
if (!lastLine.startsWith(NOTIFY_SUMMARY_PREFIX)) return null
const jsonPart = lastLine.slice(NOTIFY_SUMMARY_PREFIX.length).trim()
if (!jsonPart.startsWith('{') || !jsonPart.endsWith('}')) return null
try {
const parsed: unknown = JSON.parse(jsonPart)
if (!isObject(parsed)) return null
const result: NotifySummary = {}
if (typeof parsed.version === 'number') result.version = parsed.version
if (typeof parsed.agent === 'string') result.agent = parsed.agent
if (typeof parsed.project === 'string') result.project = parsed.project
if (typeof parsed.status === 'string') result.status = parsed.status
if (typeof parsed.action === 'string') result.action = parsed.action
if (typeof parsed.summary === 'string') result.summary = parsed.summary
return result
} catch {
return null
}
}
export type { RoleWrappedRecord }