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

View File

@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest';
import { parseHistoryMessages } from '../historyParser';
describe('parseHistoryMessages', () => {
it('returns empty array for empty input', () => {
expect(parseHistoryMessages([])).toEqual([]);
});
it('parses a simple text message', () => {
const raw = [{ id: '1', role: 'user', content: 'hello', timestamp: 1000 }];
const result = parseHistoryMessages(raw);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('user');
expect(result[0].content).toBe('hello');
expect(result[0].blocks).toEqual([{ type: 'text', text: 'hello' }]);
});
it('parses array content with text blocks', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }],
}];
const result = parseHistoryMessages(raw);
expect(result[0].content).toBe('hello world');
expect(result[0].blocks).toHaveLength(2);
});
it('parses thinking blocks', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [{ type: 'thinking', thinking: 'hmm' }, { type: 'text', text: 'answer' }],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks).toEqual([
{ type: 'thinking', text: 'hmm' },
{ type: 'text', text: 'answer' },
]);
});
it('parses image blocks with source object', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [{ type: 'image', source: { media_type: 'image/jpeg', data: 'abc' } }],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks[0]).toEqual({
type: 'image', mediaType: 'image/jpeg', data: 'abc', url: undefined,
});
});
it('parses image_url blocks', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [{ type: 'image_url', image_url: { url: 'https://example.com/img.png' } }],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks[0]).toEqual({
type: 'image', mediaType: 'image/png', url: 'https://example.com/img.png',
});
});
it('parses tool_use and tool_result blocks', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [
{ type: 'tool_use', name: 'exec', input: { cmd: 'ls' }, id: 'tc1' },
{ type: 'tool_result', content: 'file.txt', tool_use_id: 'tc1' },
],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks).toEqual([
{ type: 'tool_use', name: 'exec', input: { cmd: 'ls' }, id: 'tc1' },
{ type: 'tool_result', content: 'file.txt', toolUseId: 'tc1' },
]);
});
it('parses toolCall/toolResult (OpenAI format)', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [
{ type: 'toolCall', name: 'read', arguments: { path: '/tmp' }, id: 'tc2' },
{ type: 'toolResult', content: 'data', toolCallId: 'tc2', name: 'read' },
],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks[0]).toEqual({ type: 'tool_use', name: 'read', input: { path: '/tmp' }, id: 'tc2' });
expect(result[0].blocks[1]).toEqual({ type: 'tool_result', content: 'data', toolUseId: 'tc2', name: 'read' });
});
it('merges toolResult messages into preceding assistant message', () => {
const raw = [
{ id: '1', role: 'assistant', content: 'thinking...', timestamp: 1000 },
{ id: '2', role: 'toolResult', content: 'result data', toolCallId: 'tc1', timestamp: 1001 },
];
const result = parseHistoryMessages(raw);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('assistant');
expect(result[0].blocks).toHaveLength(2); // text + tool_result
});
it('skips orphan toolResult messages', () => {
const raw = [
{ id: '1', role: 'toolResult', content: 'orphan', toolCallId: 'tc1', timestamp: 1000 },
{ id: '2', role: 'user', content: 'hello', timestamp: 1001 },
];
const result = parseHistoryMessages(raw);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('user');
});
it('marks user system events', () => {
const raw = [{ id: '1', role: 'user', content: '[HEARTBEAT] check', timestamp: 1000 }];
const result = parseHistoryMessages(raw);
expect(result[0].isSystemEvent).toBe(true);
});
it('generates fallback ids when missing', () => {
const raw = [{ role: 'user', content: 'no id', timestamp: 1000 }];
const result = parseHistoryMessages(raw);
expect(result[0].id).toBe('hist-0');
});
it('preserves metadata excluding content/blocks', () => {
const raw = [{ id: '1', role: 'user', content: 'hi', timestamp: 1000, model: 'gpt-4' }];
const result = parseHistoryMessages(raw);
expect(result[0].metadata?.model).toBe('gpt-4');
expect(result[0].metadata?.content).toBeUndefined();
});
it('handles unknown block types gracefully (skips them)', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [{ type: 'unknown_type', data: 'foo' }, { type: 'text', text: 'ok' }],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks).toHaveLength(1);
expect(result[0].blocks[0]).toEqual({ type: 'text', text: 'ok' });
});
it('handles tool_result with object content (JSON stringified)', () => {
const raw = [{
id: '1', role: 'assistant', timestamp: 1000,
content: [{ type: 'tool_result', content: { key: 'val' }, tool_use_id: 'tc1' }],
}];
const result = parseHistoryMessages(raw);
expect(result[0].blocks[0]).toEqual({
type: 'tool_result',
content: JSON.stringify({ key: 'val' }, null, 2),
toolUseId: 'tc1',
});
});
});

129
src/lib/historyParser.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Pure function to parse raw gateway history messages into ChatMessage[].
* Shared between useGateway and useSecondarySession to avoid duplication.
*/
import type { ChatMessage, MessageBlock } from '../types';
import { isSystemEvent } from './systemEvent';
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway messages have dynamic shape */
/** Parse a single content block from a raw history message. */
function parseContentBlock(block: Record<string, any>): MessageBlock | null {
switch (block.type) {
case 'text':
return { type: 'text', text: block.text };
case 'thinking':
return { type: 'thinking', text: block.thinking || block.text || '' };
case 'image': {
const src = block.source || {};
return {
type: 'image',
mediaType: src.media_type || block.media_type || 'image/png',
data: src.data || block.data,
url: block.url || src.url,
};
}
case 'image_url':
return { type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url };
case 'tool_use':
return { type: 'tool_use', name: block.name, input: block.input, id: block.id };
case 'tool_result':
return {
type: 'tool_result',
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2),
toolUseId: block.tool_use_id,
};
case 'toolCall':
return { type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id };
case 'toolResult':
return {
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,
};
default:
return null;
}
}
/** Parse content field (string or array) into MessageBlock[]. */
function parseContent(content: unknown): MessageBlock[] {
if (!content) return [];
if (typeof content === 'string') return [{ type: 'text', text: content }];
if (Array.isArray(content)) {
const blocks: MessageBlock[] = [];
for (const block of content) {
const parsed = parseContentBlock(block);
if (parsed) blocks.push(parsed);
}
return blocks;
}
return [];
}
/**
* Parse raw gateway history messages into ChatMessage[].
* Merges tool results into their parent assistant messages.
*/
export function parseHistoryMessages(rawMsgs: Array<Record<string, any>>): ChatMessage[] {
const msgs: (ChatMessage & { isToolResult?: boolean })[] = rawMsgs.map((m, i) => {
const blocks = parseContent(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),
};
});
// Merge tool results into preceding assistant message
const merged: ChatMessage[] = [];
for (const msg of msgs) {
if (msg.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 (!msg.isToolResult) {
merged.push(msg);
}
}
return merged;
}
/* eslint-enable @typescript-eslint/no-explicit-any */