refactor: extract history parsing into shared historyParser lib with tests

- Extract duplicated raw message parsing from useGateway and useSecondarySession into src/lib/historyParser.ts
- Add 15 tests covering text, thinking, image, tool_use, tool_result, toolCall/toolResult formats, merging, orphan handling, metadata, and edge cases
- Remove ~126 lines of duplicated code across the two hooks
- Slight bundle size reduction (index: 142.11→140.29 KB)
This commit is contained in:
Nicolas Varrot
2026-03-04 09:05:15 +00:00
parent b7c18d5f3c
commit e2d68925ce
4 changed files with 285 additions and 126 deletions

View File

@@ -3,10 +3,10 @@ import { GatewayClient, type JsonPayload } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils';
import { getStoredCredentials, storeCredentials, clearCredentials, type AuthMode } from '../lib/credentials';
import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity';
import { isSystemEvent } from '../lib/systemEvent';
import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache';
import { extractAgentIdFromKey } from '../lib/sessionName';
import { extractText, extractThinking, type ChatPayloadMessage } from '../lib/messageExtract';
import { parseHistoryMessages } from '../lib/historyParser';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types';
export function useGateway() {
@@ -143,80 +143,7 @@ export function useGateway() {
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
if (rawMsgs) {
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, i: number) => {
const blocks: MessageBlock[] = [];
if (m.content) {
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
else if (block.type === 'image') {
const src = block.source || {};
blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url });
}
else if (block.type === 'image_url') {
blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url });
}
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
}
} else if (typeof m.content === 'string') {
blocks.push({ type: 'text', text: m.content });
}
}
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
if (m.role === 'toolResult') {
const toolBlocks: MessageBlock[] = blocks.map(b => {
if (b.type === 'text') {
return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
}
return b;
});
return {
id: m.id || `hist-${i}`,
role: 'assistant' as const,
content: '',
timestamp: m.timestamp || Date.now(),
blocks: toolBlocks,
isToolResult: true,
};
}
const textContent = blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('');
// Capture raw metadata (exclude heavy fields already parsed)
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m)) {
if (['content', 'blocks'].includes(k)) continue;
metadata[k] = v;
}
return {
id: m.id || `hist-${i}`,
role,
content: textContent,
timestamp: m.timestamp || Date.now(),
blocks,
metadata,
isSystemEvent: role === 'user' && isSystemEvent(textContent),
};
});
const merged: ChatMessage[] = [];
for (const msg of msgs) {
const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult;
if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
merged[merged.length - 1] = {
...merged[merged.length - 1],
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
};
} else if (isToolResult) {
// skip orphan tool results
} else {
merged.push(msg);
}
}
const merged = parseHistoryMessages(rawMsgs as Array<Record<string, any>>); // eslint-disable-line @typescript-eslint/no-explicit-any
// Apply stored generation time to the last assistant message if available
const genKey = sessionKey + ':latest';
const genTime = generationTimesRef.current.get(genKey);

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import type { GatewayClient, JsonPayload } from '../lib/gateway';
import type { ChatMessage, MessageBlock } from '../types';
import { isSystemEvent } from '../lib/systemEvent';
import { parseHistoryMessages } from '../lib/historyParser';
import { extractText, extractThinking } from '../lib/messageExtract';
import type { ChatPayloadMessage } from '../lib/messageExtract';
@@ -28,56 +28,7 @@ export function useSecondarySession(
const res = await getClient()?.send('chat.history', { sessionKey: key, limit: 100 });
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
if (!rawMsgs) return;
/* eslint-disable @typescript-eslint/no-explicit-any */
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, i: number) => {
const blocks: MessageBlock[] = [];
if (m.content) {
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
else if (block.type === 'image') {
const src = block.source || {};
blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url });
}
else if (block.type === 'image_url') {
blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url });
}
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
}
} else if (typeof m.content === 'string') {
blocks.push({ type: 'text', text: m.content });
}
}
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
if (m.role === 'toolResult') {
const toolBlocks: MessageBlock[] = blocks.map(b => {
if (b.type === 'text') return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
return b;
});
return { id: m.id || `hist-${i}`, role: 'assistant' as const, content: '', timestamp: m.timestamp || Date.now(), blocks: toolBlocks, isToolResult: true };
}
const textContent = blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('');
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m)) {
if (['content', 'blocks'].includes(k)) continue;
metadata[k] = v;
}
return { id: m.id || `hist-${i}`, role, content: textContent, timestamp: m.timestamp || Date.now(), blocks, metadata, isSystemEvent: role === 'user' && isSystemEvent(textContent) };
});
/* eslint-enable @typescript-eslint/no-explicit-any */
const merged: ChatMessage[] = [];
for (const msg of msgs) {
const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult;
if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
merged[merged.length - 1] = { ...merged[merged.length - 1], blocks: [...merged[merged.length - 1].blocks, ...msg.blocks] };
} else if (!isToolResult) {
merged.push(msg);
}
}
const merged = parseHistoryMessages(rawMsgs as Array<Record<string, any>>); // eslint-disable-line @typescript-eslint/no-explicit-any
setMessages(merged);
} catch {
// ignore