From 59a4da193384ce5a9f02fc2a8f6eb29bd56648e0 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Mon, 23 Feb 2026 21:02:45 +0000 Subject: [PATCH] refactor: extract shared message parsing helpers into lib/messageExtract - Move extractText and extractThinking from useGateway and useSecondarySession into shared lib - Add comprehensive unit tests (13 tests) for both functions - Eliminate code duplication between the two hooks --- src/hooks/useGateway.ts | 28 +------- src/hooks/useSecondarySession.ts | 29 +------- src/lib/__tests__/messageExtract.test.ts | 90 ++++++++++++++++++++++++ src/lib/messageExtract.ts | 32 +++++++++ 4 files changed, 125 insertions(+), 54 deletions(-) create mode 100644 src/lib/__tests__/messageExtract.test.ts create mode 100644 src/lib/messageExtract.ts diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index b44a5f2..6350d30 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -5,35 +5,9 @@ import { getStoredCredentials, storeCredentials, clearCredentials, type AuthMode import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity'; import { isSystemEvent } from '../lib/systemEvent'; import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache'; +import { extractText, extractThinking, type ChatPayloadMessage } from '../lib/messageExtract'; import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types'; -interface ChatPayloadMessage { - content?: string | Array<{ type: string; text?: string; thinking?: string }>; -} - -function extractText(message: ChatPayloadMessage | undefined): string { - if (!message) return ''; - const content = message.content; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter((b) => b.type === 'text' && typeof b.text === 'string') - .map((b) => b.text as string) - .join('\n'); - } - return ''; -} - -function extractThinking(message: ChatPayloadMessage | undefined): string { - if (!message) return ''; - const content = message.content; - if (!Array.isArray(content)) return ''; - return content - .filter((b) => b.type === 'thinking') - .map((b) => b.thinking || b.text || '') - .join('\n'); -} - export function useGateway() { const clientRef = useRef(null); const [status, setStatus] = useState('disconnected'); diff --git a/src/hooks/useSecondarySession.ts b/src/hooks/useSecondarySession.ts index 1a6a014..4b6916d 100644 --- a/src/hooks/useSecondarySession.ts +++ b/src/hooks/useSecondarySession.ts @@ -2,33 +2,8 @@ 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'; - -interface ChatPayloadMessage { - content?: string | Array<{ type: string; text?: string; thinking?: string }>; -} - -function extractText(message: ChatPayloadMessage | undefined): string { - if (!message) return ''; - const content = message.content; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter((b) => b.type === 'text' && typeof b.text === 'string') - .map((b) => b.text as string) - .join('\n'); - } - return ''; -} - -function extractThinking(message: ChatPayloadMessage | undefined): string { - if (!message) return ''; - const content = message.content; - if (!Array.isArray(content)) return ''; - return content - .filter((b) => b.type === 'thinking') - .map((b) => b.thinking || b.text || '') - .join('\n'); -} +import { extractText, extractThinking } from '../lib/messageExtract'; +import type { ChatPayloadMessage } from '../lib/messageExtract'; /** * Hook to manage a secondary session for split view. diff --git a/src/lib/__tests__/messageExtract.test.ts b/src/lib/__tests__/messageExtract.test.ts new file mode 100644 index 0000000..3dcf45b --- /dev/null +++ b/src/lib/__tests__/messageExtract.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { extractText, extractThinking } from '../messageExtract'; + +describe('extractText', () => { + it('returns empty string for undefined', () => { + expect(extractText(undefined)).toBe(''); + }); + + it('returns empty string for empty message', () => { + expect(extractText({})).toBe(''); + }); + + it('returns string content directly', () => { + expect(extractText({ content: 'hello world' })).toBe('hello world'); + }); + + it('extracts text blocks from array content', () => { + expect(extractText({ + content: [ + { type: 'text', text: 'hello' }, + { type: 'thinking', thinking: 'hmm' }, + { type: 'text', text: 'world' }, + ], + })).toBe('hello\nworld'); + }); + + it('ignores non-text blocks', () => { + expect(extractText({ + content: [ + { type: 'thinking', text: 'reasoning' }, + { type: 'image', text: 'img' }, + ], + })).toBe(''); + }); + + it('skips text blocks with undefined text', () => { + expect(extractText({ + content: [ + { type: 'text' }, + { type: 'text', text: 'ok' }, + ], + })).toBe('ok'); + }); + + it('returns empty string for non-string non-array content', () => { + expect(extractText({ content: 42 as unknown as string })).toBe(''); + }); +}); + +describe('extractThinking', () => { + it('returns empty string for undefined', () => { + expect(extractThinking(undefined)).toBe(''); + }); + + it('returns empty string for string content', () => { + expect(extractThinking({ content: 'hello' })).toBe(''); + }); + + it('extracts thinking blocks using thinking field', () => { + expect(extractThinking({ + content: [ + { type: 'text', text: 'hello' }, + { type: 'thinking', thinking: 'let me think' }, + ], + })).toBe('let me think'); + }); + + it('falls back to text field when thinking is absent', () => { + expect(extractThinking({ + content: [ + { type: 'thinking', text: 'fallback' }, + ], + })).toBe('fallback'); + }); + + it('joins multiple thinking blocks', () => { + expect(extractThinking({ + content: [ + { type: 'thinking', thinking: 'step 1' }, + { type: 'thinking', thinking: 'step 2' }, + ], + })).toBe('step 1\nstep 2'); + }); + + it('returns empty string for thinking block with no content', () => { + expect(extractThinking({ + content: [{ type: 'thinking' }], + })).toBe(''); + }); +}); diff --git a/src/lib/messageExtract.ts b/src/lib/messageExtract.ts new file mode 100644 index 0000000..15d346a --- /dev/null +++ b/src/lib/messageExtract.ts @@ -0,0 +1,32 @@ +/** + * Shared helpers for extracting text and thinking content from gateway chat messages. + */ + +export interface ChatPayloadMessage { + content?: string | Array<{ type: string; text?: string; thinking?: string }>; +} + +/** Extract all text blocks from a gateway chat message, joined by newline. */ +export function extractText(message: ChatPayloadMessage | undefined): string { + if (!message) return ''; + const content = message.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((b) => b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text as string) + .join('\n'); + } + return ''; +} + +/** Extract all thinking blocks from a gateway chat message, joined by newline. */ +export function extractThinking(message: ChatPayloadMessage | undefined): string { + if (!message) return ''; + const content = message.content; + if (!Array.isArray(content)) return ''; + return content + .filter((b) => b.type === 'thinking') + .map((b) => b.thinking || b.text || '') + .join('\n'); +}