/** * Parsed message types and type guards for claude-devtools. * * ParsedMessage is the application's internal representation after parsing * raw JSONL entries. This module also contains type guards for classifying * parsed messages into categories for chunk building. */import { EMPTY_STDERR, EMPTY_STDOUT, HARD_NOISE_TAGS, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, SYSTEM_OUTPUT_TAGS,} from '../constants/messageTags';import { type MessageType, type TokenUsage } from './domain';import { type ContentBlock, type ToolUseResultData } from './jsonl';// =============================================================================// Tool Types// =============================================================================/** * Tool call extracted from assistant message. */export interface ToolCall { /** Tool use ID for linking to results */ id: string; /** Tool name */ name: string; /** Tool input parameters */ input: Record<string, unknown>; /** Whether this is a Task (subagent) tool call */ isTask: boolean; /** Task description if isTask */ taskDescription?: string; /** Task subagent type if isTask */ taskSubagentType?: string;}/** * Tool result extracted from user message. */export interface ToolResult { /** Corresponding tool_use ID */ toolUseId: string; /** Result content */ content: string | unknown[]; /** Whether the tool execution errored */ isError: boolean;}// =============================================================================// Parsed Message// =============================================================================/** * Parsed and enriched message from JSONL. * This is the application's internal representation after parsing raw JSONL entries. */export interface ParsedMessage { /** Unique message identifier */ uuid: string; /** Parent message UUID for threading */ parentUuid: string | null; /** Message type */ type: MessageType; /** Message timestamp */ timestamp: Date; /** Message role if present */ role?: string; /** Message content (string or content blocks) */ content: ContentBlock[] | string; /** Token usage for this message */ usage?: TokenUsage; /** Model used for this response */ model?: string; // Metadata /** Current working directory when message was created */ cwd?: string; /** Git branch context */ gitBranch?: string; /** Agent ID for subagent messages */ agentId?: string; /** Whether this is a sidechain message */ isSidechain: boolean; /** Whether this is a meta message */ isMeta: boolean; /** User type ("external" for user input) */ userType?: string; // Extracted tool information /** Tool calls made in this message */ toolCalls: ToolCall[]; /** Tool results received in this message */ toolResults: ToolResult[]; /** Source tool use ID if this is a tool result message */ sourceToolUseID?: string; /** Source assistant UUID if this is a tool result message */ sourceToolAssistantUUID?: string; /** Tool use result information if this is a tool result message */ toolUseResult?: ToolUseResultData; /** Whether this is a compact summary boundary message */ isCompactSummary?: boolean;}// =============================================================================// ParsedMessage Type Guards// =============================================================================/** * Type guard to check if a ParsedMessage is a real user message. * This wraps the spec's type guard but works with ParsedMessage instead of UserEntry. * * Accepts both formats: * - Older sessions: content as string * - Newer sessions: content as array with text/image blocks * * Excludes command output messages (with <local-command-stdout>) which should * be treated as system responses, not user input that starts new chunks. */export function isParsedRealUserMessage(msg: ParsedMessage): boolean { if (msg.type !== 'user') return false; if (msg.isMeta) return false; const content = msg.content; // String content format (older sessions) if (typeof content === 'string') { return true; } // Array content format (newer sessions) if (Array.isArray(content)) { // Check if it contains text or image blocks (real user input) // Exclude arrays with only tool_result blocks (those are internal messages) return content.some((block) => block.type === 'text' || block.type === 'image'); } return false;}/** * Type guard for User chunk creation - genuine user input that starts User chunks. * * Returns true if message should create a User chunk: * - type='user' * - isMeta!=true * - Has text/image content * - Content does NOT contain: <local-command-stdout>, <local-command-caveat>, <system-reminder> * - Content MAY contain: <command-name> (slash commands like /model ARE user input) * * Example User chunk messages: * - "Help me debug this code" * - "<command-name>/model</command-name> Switch to sonnet" * * NOT User chunks: * - "<local-command-stdout>Set model to...</local-command-stdout>" -> System chunk * - "<local-command-caveat>...</local-command-caveat>" -> Hard noise * - "<system-reminder>...</system-reminder>" -> Hard noise */export function isParsedUserChunkMessage(msg: ParsedMessage): boolean { if (msg.type !== 'user') return false; if (msg.isMeta === true) return false; if (isParsedTeammateMessage(msg)) return false; const content = msg.content; // Check string content if (typeof content === 'string') { const trimmed = content.trim(); // Exclude messages that are system output or system metadata // These tags indicate system-generated content, not user input for (const tag of SYSTEM_OUTPUT_TAGS) { if (trimmed.startsWith(tag)) { return false; } } // <command-name> is ALLOWED - it's user-initiated slash commands // Remaining content is genuine user input return trimmed.length > 0; } // Array content format (newer sessions) if (Array.isArray(content)) { // Must contain text or image blocks for real user input const hasUserContent = content.some((block) => block.type === 'text' || block.type === 'image'); if (!hasUserContent) { return false; } // Filter out user interruption messages (should be part of AI response flow) // These have exactly 1 text block with content like "[Request interrupted by user]" // or "[Request interrupted by user for tool use]" if ( content.length === 1 && content[0].type === 'text' && typeof content[0].text === 'string' && content[0].text.startsWith('[Request interrupted by user') ) { return false; } // Check text blocks for excluded tags for (const block of content) { if (block.type === 'text') { const textBlock = block; for (const tag of SYSTEM_OUTPUT_TAGS) { if (textBlock.text.startsWith(tag)) { return false; } } } } return true; } return false;}/** * Type guard for System chunk creation - command output messages. * * Returns true if message should create a System chunk: * - type='user' (confusingly, command output comes as user entries in JSONL) * - Contains <local-command-stdout> tag * * System chunks render on the LEFT side (like AI responses) with neutral gray styling. * * Example: * ``` * { * type: "user", * content: "<local-command-stdout>Set model to sonnet...</local-command-stdout>" * } * ``` */export function isParsedSystemChunkMessage(msg: ParsedMessage): boolean { if (msg.type !== 'user') return false; const content = msg.content; if (typeof content === 'string') { return ( content.startsWith(LOCAL_COMMAND_STDOUT_TAG) || content.startsWith(LOCAL_COMMAND_STDERR_TAG) ); } // Array content - check text blocks if (Array.isArray(content)) { return content.some( (block) => block.type === 'text' && block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG) ); } return false;}/** * Type guard to check if a ParsedMessage is an internal user message. * This wraps the spec's type guard but works with ParsedMessage instead of UserEntry. */export function isParsedInternalUserMessage(msg: ParsedMessage): boolean { return msg.type === 'user' && msg.isMeta === true;}/** * Hard noise message (ParsedMessage version) - NEVER rendered or counted in the UI. * This wraps isHardNoiseMessage() but works with ParsedMessage instead of ChatHistoryEntry. * * Filtered messages: * - Messages with parentUuid: null (orphaned/root messages that shouldn't display) * - e.g., compact_boundary system messages, root-level meta messages * * Filtered types: * - 'system' entries * - 'summary' entries * - 'file-history-snapshot' entries * - 'queue-operation' entries * * Filtered user messages: * - Messages containing ONLY these system metadata tags (no real content): * - <local-command-caveat> * - <system-reminder> * - Empty command output: <local-command-stdout></local-command-stdout> * - Interruption messages: [Request interrupted by user...] * * Filtered assistant messages: * - Synthetic messages with model='<synthetic>' (system-generated placeholders) */export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean { // Filter structural metadata types - these should never be displayed if (msg.type === 'system') return true; if (msg.type === 'summary') return true; if (msg.type === 'file-history-snapshot') return true; if (msg.type === 'queue-operation') return true; // Filter synthetic assistant messages (system-generated placeholders) if (msg.type === 'assistant' && msg.model === '<synthetic>') { return true; } // Filter user messages with ONLY system metadata tags (no real content) if (msg.type === 'user') { const content = msg.content; if (typeof content === 'string') { // Check if content contains ONLY noise tags (trim whitespace) const trimmedContent = content.trim(); // If the content is wrapped in a noise tag, it's hard noise for (const tag of HARD_NOISE_TAGS) { const openTag = tag; const closeTag = tag.replace('<', '</'); if (trimmedContent.startsWith(openTag) && trimmedContent.endsWith(closeTag)) { return true; } } // Filter empty command output (e.g., /clear with no output) if (trimmedContent === EMPTY_STDOUT || trimmedContent === EMPTY_STDERR) { return true; } // Filter interruption messages (rendered via session state detection instead) if (trimmedContent.startsWith('[Request interrupted by user')) { return true; } } // Filter array content with single interruption text block if (Array.isArray(content)) { if ( content.length === 1 && content[0].type === 'text' && typeof content[0].text === 'string' && content[0].text.startsWith('[Request interrupted by user') ) { return true; } } } return false;}/** * Detect compact summary messages. * These are markers indicating conversation was compacted. */export function isParsedCompactMessage(msg: ParsedMessage): boolean { return msg.isCompactSummary === true;}/** * Detect teammate messages - messages from team member agents. * Format: <teammate-message teammate_id="name" ...>content</teammate-message> */const TEAMMATE_MESSAGE_REGEX = /^<teammate-message\s+teammate_id="([^"]+)"/;function isParsedTeammateMessage(msg: ParsedMessage): boolean { if (msg.type !== 'user' || msg.isMeta) return false; const content = msg.content; if (typeof content === 'string') return TEAMMATE_MESSAGE_REGEX.test(content.trim()); if (Array.isArray(content)) { return content.some( (block) => block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim()) ); } return false;}
/**
* Parsed message types and type guards for claude-devtools.
*
* ParsedMessage is the application's internal representation after parsing
* raw JSONL entries. This module also contains type guards for classifying
* parsed messages into categories for chunk building.
*/
import {
EMPTY_STDERR,
EMPTY_STDOUT,
HARD_NOISE_TAGS,
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG,
SYSTEM_OUTPUT_TAGS,
} from '../constants/messageTags';
import { type MessageType, type TokenUsage } from './domain';
import { type ContentBlock, type ToolUseResultData } from './jsonl';
// =============================================================================
// Tool Types
// =============================================================================
/**
* Tool call extracted from assistant message.
*/
export interface ToolCall {
/** Tool use ID for linking to results */
id: string;
/** Tool name */
name: string;
/** Tool input parameters */
input: Record<string, unknown>;
/** Whether this is a Task (subagent) tool call */
isTask: boolean;
/** Task description if isTask */
taskDescription?: string;
/** Task subagent type if isTask */
taskSubagentType?: string;
}
/**
* Tool result extracted from user message.
*/
export interface ToolResult {
/** Corresponding tool_use ID */
toolUseId: string;
/** Result content */
content: string | unknown[];
/** Whether the tool execution errored */
isError: boolean;
}
// =============================================================================
// Parsed Message
// =============================================================================
/**
* Parsed and enriched message from JSONL.
* This is the application's internal representation after parsing raw JSONL entries.
*/
export interface ParsedMessage {
/** Unique message identifier */
uuid: string;
/** Parent message UUID for threading */
parentUuid: string | null;
/** Message type */
type: MessageType;
/** Message timestamp */
timestamp: Date;
/** Message role if present */
role?: string;
/** Message content (string or content blocks) */
content: ContentBlock[] | string;
/** Token usage for this message */
usage?: TokenUsage;
/** Model used for this response */
model?: string;
// Metadata
/** Current working directory when message was created */
cwd?: string;
/** Git branch context */
gitBranch?: string;
/** Agent ID for subagent messages */
agentId?: string;
/** Whether this is a sidechain message */
isSidechain: boolean;
/** Whether this is a meta message */
isMeta: boolean;
/** User type ("external" for user input) */
userType?: string;
// Extracted tool information
/** Tool calls made in this message */
toolCalls: ToolCall[];
/** Tool results received in this message */
toolResults: ToolResult[];
/** Source tool use ID if this is a tool result message */
sourceToolUseID?: string;
/** Source assistant UUID if this is a tool result message */
sourceToolAssistantUUID?: string;
/** Tool use result information if this is a tool result message */
toolUseResult?: ToolUseResultData;
/** Whether this is a compact summary boundary message */
isCompactSummary?: boolean;
}
// =============================================================================
// ParsedMessage Type Guards
// =============================================================================
/**
* Type guard to check if a ParsedMessage is a real user message.
* This wraps the spec's type guard but works with ParsedMessage instead of UserEntry.
*
* Accepts both formats:
* - Older sessions: content as string
* - Newer sessions: content as array with text/image blocks
*
* Excludes command output messages (with <local-command-stdout>) which should
* be treated as system responses, not user input that starts new chunks.
*/
export function isParsedRealUserMessage(msg: ParsedMessage): boolean {
if (msg.type !== 'user') return false;
if (msg.isMeta) return false;
const content = msg.content;
// String content format (older sessions)
if (typeof content === 'string') {
return true;
}
// Array content format (newer sessions)
if (Array.isArray(content)) {
// Check if it contains text or image blocks (real user input)
// Exclude arrays with only tool_result blocks (those are internal messages)
return content.some((block) => block.type === 'text' || block.type === 'image');
}
return false;
}
/**
* Type guard for User chunk creation - genuine user input that starts User chunks.
*
* Returns true if message should create a User chunk:
* - type='user'
* - isMeta!=true
* - Has text/image content
* - Content does NOT contain: <local-command-stdout>, <local-command-caveat>, <system-reminder>
* - Content MAY contain: <command-name> (slash commands like /model ARE user input)
*
* Example User chunk messages:
* - "Help me debug this code"
* - "<command-name>/model</command-name> Switch to sonnet"
*
* NOT User chunks:
* - "<local-command-stdout>Set model to...</local-command-stdout>" -> System chunk
* - "<local-command-caveat>...</local-command-caveat>" -> Hard noise
* - "<system-reminder>...</system-reminder>" -> Hard noise
*/
export function isParsedUserChunkMessage(msg: ParsedMessage): boolean {
if (msg.type !== 'user') return false;
if (msg.isMeta === true) return false;
if (isParsedTeammateMessage(msg)) return false;
const content = msg.content;
// Check string content
if (typeof content === 'string') {
const trimmed = content.trim();
// Exclude messages that are system output or system metadata
// These tags indicate system-generated content, not user input
for (const tag of SYSTEM_OUTPUT_TAGS) {
if (trimmed.startsWith(tag)) {
return false;
}
}
// <command-name> is ALLOWED - it's user-initiated slash commands
// Remaining content is genuine user input
return trimmed.length > 0;
}
// Array content format (newer sessions)
if (Array.isArray(content)) {
// Must contain text or image blocks for real user input
const hasUserContent = content.some((block) => block.type === 'text' || block.type === 'image');
if (!hasUserContent) {
return false;
}
// Filter out user interruption messages (should be part of AI response flow)
// These have exactly 1 text block with content like "[Request interrupted by user]"
// or "[Request interrupted by user for tool use]"
if (
content.length === 1 &&
content[0].type === 'text' &&
typeof content[0].text === 'string' &&
content[0].text.startsWith('[Request interrupted by user')
) {
return false;
}
// Check text blocks for excluded tags
for (const block of content) {
if (block.type === 'text') {
const textBlock = block;
for (const tag of SYSTEM_OUTPUT_TAGS) {
if (textBlock.text.startsWith(tag)) {
return false;
}
}
}
}
return true;
}
return false;
}
/**
* Type guard for System chunk creation - command output messages.
*
* Returns true if message should create a System chunk:
* - type='user' (confusingly, command output comes as user entries in JSONL)
* - Contains <local-command-stdout> tag
*
* System chunks render on the LEFT side (like AI responses) with neutral gray styling.
*
* Example:
* ```
* {
* type: "user",
* content: "<local-command-stdout>Set model to sonnet...</local-command-stdout>"
* }
* ```
*/
export function isParsedSystemChunkMessage(msg: ParsedMessage): boolean {
if (msg.type !== 'user') return false;
const content = msg.content;
if (typeof content === 'string') {
return (
content.startsWith(LOCAL_COMMAND_STDOUT_TAG) || content.startsWith(LOCAL_COMMAND_STDERR_TAG)
);
}
// Array content - check text blocks
if (Array.isArray(content)) {
return content.some(
(block) => block.type === 'text' && block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG)
);
}
return false;
}
/**
* Type guard to check if a ParsedMessage is an internal user message.
* This wraps the spec's type guard but works with ParsedMessage instead of UserEntry.
*/
export function isParsedInternalUserMessage(msg: ParsedMessage): boolean {
return msg.type === 'user' && msg.isMeta === true;
}
/**
* Hard noise message (ParsedMessage version) - NEVER rendered or counted in the UI.
* This wraps isHardNoiseMessage() but works with ParsedMessage instead of ChatHistoryEntry.
*
* Filtered messages:
* - Messages with parentUuid: null (orphaned/root messages that shouldn't display)
* - e.g., compact_boundary system messages, root-level meta messages
*
* Filtered types:
* - 'system' entries
* - 'summary' entries
* - 'file-history-snapshot' entries
* - 'queue-operation' entries
*
* Filtered user messages:
* - Messages containing ONLY these system metadata tags (no real content):
* - <local-command-caveat>
* - <system-reminder>
* - Empty command output: <local-command-stdout></local-command-stdout>
* - Interruption messages: [Request interrupted by user...]
*
* Filtered assistant messages:
* - Synthetic messages with model='<synthetic>' (system-generated placeholders)
*/
export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
// Filter structural metadata types - these should never be displayed
if (msg.type === 'system') return true;
if (msg.type === 'summary') return true;
if (msg.type === 'file-history-snapshot') return true;
if (msg.type === 'queue-operation') return true;
// Filter synthetic assistant messages (system-generated placeholders)
if (msg.type === 'assistant' && msg.model === '<synthetic>') {
return true;
}
// Filter user messages with ONLY system metadata tags (no real content)
if (msg.type === 'user') {
const content = msg.content;
if (typeof content === 'string') {
// Check if content contains ONLY noise tags (trim whitespace)
const trimmedContent = content.trim();
// If the content is wrapped in a noise tag, it's hard noise
for (const tag of HARD_NOISE_TAGS) {
const openTag = tag;
const closeTag = tag.replace('<', '</');
if (trimmedContent.startsWith(openTag) && trimmedContent.endsWith(closeTag)) {
return true;
}
}
// Filter empty command output (e.g., /clear with no output)
if (trimmedContent === EMPTY_STDOUT || trimmedContent === EMPTY_STDERR) {
return true;
}
// Filter interruption messages (rendered via session state detection instead)
if (trimmedContent.startsWith('[Request interrupted by user')) {
return true;
}
}
// Filter array content with single interruption text block
if (Array.isArray(content)) {
if (
content.length === 1 &&
content[0].type === 'text' &&
typeof content[0].text === 'string' &&
content[0].text.startsWith('[Request interrupted by user')
) {
return true;
}
}
}
return false;
}
/**
* Detect compact summary messages.
* These are markers indicating conversation was compacted.
*/
export function isParsedCompactMessage(msg: ParsedMessage): boolean {
return msg.isCompactSummary === true;
}
/**
* Detect teammate messages - messages from team member agents.
* Format: <teammate-message teammate_id="name" ...>content</teammate-message>
*/
const TEAMMATE_MESSAGE_REGEX = /^<teammate-message\s+teammate_id="([^"]+)"/;
function isParsedTeammateMessage(msg: ParsedMessage): boolean {
if (msg.type !== 'user' || msg.isMeta) return false;
const content = msg.content;
if (typeof content === 'string') return TEAMMATE_MESSAGE_REGEX.test(content.trim());
if (Array.isArray(content)) {
return content.some(
(block) => block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim())
);
}
return false;
}