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
This commit is contained in:
Nicolas Varrot
2026-02-23 21:02:45 +00:00
parent ecdff58dca
commit 59a4da1933
4 changed files with 125 additions and 54 deletions

View File

@@ -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<GatewayClient | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');

View File

@@ -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.

View File

@@ -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('');
});
});

32
src/lib/messageExtract.ts Normal file
View File

@@ -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');
}