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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
152
src/lib/__tests__/historyParser.test.ts
Normal file
152
src/lib/__tests__/historyParser.test.ts
Normal 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
129
src/lib/historyParser.ts
Normal 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 */
|
||||
Reference in New Issue
Block a user