diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index fef7308..19f7dfe 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -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> | undefined; if (rawMsgs) { - /* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */ - const msgs: ChatMessage[] = rawMsgs.map((m: Record, 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 => b.type === 'text').map(b => b.text).join(''); - // Capture raw metadata (exclude heavy fields already parsed) - const metadata: Record = {}; - 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>); // 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); diff --git a/src/hooks/useSecondarySession.ts b/src/hooks/useSecondarySession.ts index 4b6916d..ae3c38a 100644 --- a/src/hooks/useSecondarySession.ts +++ b/src/hooks/useSecondarySession.ts @@ -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> | undefined; if (!rawMsgs) return; - /* eslint-disable @typescript-eslint/no-explicit-any */ - const msgs: ChatMessage[] = rawMsgs.map((m: Record, 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 => b.type === 'text').map(b => b.text).join(''); - const metadata: Record = {}; - 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>); // eslint-disable-line @typescript-eslint/no-explicit-any setMessages(merged); } catch { // ignore diff --git a/src/lib/__tests__/historyParser.test.ts b/src/lib/__tests__/historyParser.test.ts new file mode 100644 index 0000000..2824db4 --- /dev/null +++ b/src/lib/__tests__/historyParser.test.ts @@ -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', + }); + }); +}); diff --git a/src/lib/historyParser.ts b/src/lib/historyParser.ts new file mode 100644 index 0000000..a3617ca --- /dev/null +++ b/src/lib/historyParser.ts @@ -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): 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>): 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 => b.type === 'text') + .map(b => b.text) + .join(''); + + const metadata: Record = {}; + 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 */