File size: 5,161 Bytes
077865a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import { describe, it, expect } from 'vitest';
import { contentToString, flattenMessageContent, messageHasImage, normalizeOutboundContent } from '../../lib/content.js';

describe('contentToString', () => {
  it('passes strings through', () => {
    expect(contentToString('hello')).toBe('hello');
    expect(contentToString('')).toBe('');
  });

  it('treats null and undefined as empty string', () => {
    expect(contentToString(null)).toBe('');
    expect(contentToString(undefined)).toBe('');
  });

  it('joins text blocks in OpenAI multimodal array envelope', () => {
    expect(contentToString([
      { type: 'text', text: 'hello ' },
      { type: 'text', text: 'world' },
    ])).toBe('hello world');
  });

  it('drops non-text blocks (image_url etc.) — text-only providers flatten this way', () => {
    expect(contentToString([
      { type: 'text', text: 'describe ' },
      { type: 'image_url', image_url: { url: 'https://example.com/x.png' } },
      { type: 'text', text: 'this' },
    ])).toBe('describe this');
  });

  it('extracts Gemini-part-style { text } blocks with no type field (#200)', () => {
    expect(contentToString([{ text: 'hi' }, { text: ' there' }])).toBe('hi there');
    // a typed NON-text block still gets dropped even when it carries text
    expect(contentToString([{ type: 'reasoning', text: 'inner monologue' }, { type: 'text', text: 'ok' }])).toBe('ok');
  });

  it('handles an array of bare strings (some clients send this)', () => {
    expect(contentToString(['foo', 'bar'])).toBe('foobar');
  });

  it('returns empty string for unrecognized types instead of throwing', () => {
    expect(contentToString(42 as unknown)).toBe('');
    expect(contentToString({ unknown: true } as unknown)).toBe('');
  });
});

describe('flattenMessageContent', () => {
  it('converts every message content to a string', () => {
    const out = flattenMessageContent([
      { role: 'user', content: 'plain' },
      { role: 'user', content: [{ type: 'text', text: 'array' }] },
      { role: 'assistant', content: null, tool_calls: [{ id: 'x', type: 'function', function: { name: 'f', arguments: '{}' } }] },
    ]);
    expect(out[0].content).toBe('plain');
    expect(out[1].content).toBe('array');
    expect(out[2].content).toBe('');
  });

  it('preserves other message fields (tool_calls, name, tool_call_id)', () => {
    const out = flattenMessageContent([
      { role: 'tool', content: 'result', tool_call_id: 'call-1', name: 'fn' },
    ]);
    expect(out[0]).toMatchObject({
      role: 'tool',
      content: 'result',
      tool_call_id: 'call-1',
      name: 'fn',
    });
  });
});

describe('messageHasImage', () => {
  it('detects image_url blocks (OpenAI vision envelope)', () => {
    expect(messageHasImage([
      { role: 'user', content: [
        { type: 'text', text: 'what is this?' },
        { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
      ] },
    ])).toBe(true);
  });

  it('detects a bare image block type', () => {
    expect(messageHasImage([
      { role: 'user', content: [{ type: 'image', source: 'x' } as any] },
    ])).toBe(true);
  });

  it('is false for string content and text-only arrays', () => {
    expect(messageHasImage([{ role: 'user', content: 'hello' }])).toBe(false);
    expect(messageHasImage([
      { role: 'user', content: [{ type: 'text', text: 'hello' }] },
    ])).toBe(false);
    expect(messageHasImage([{ role: 'assistant', content: null }])).toBe(false);
  });
});

describe('normalizeOutboundContent (#166)', () => {
  it('coerces array delta.content to a string on streaming chunks', () => {
    const chunk = { choices: [{ index: 0, delta: { content: [{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }] } }] };
    const out = normalizeOutboundContent(chunk);
    expect(out.choices[0].delta.content).toBe('hello');
  });

  it('coerces array message.content to a string on non-stream responses', () => {
    const result = { choices: [{ index: 0, message: { role: 'assistant', content: [{ type: 'text', text: 'got it' }] } }] };
    const out = normalizeOutboundContent(result);
    expect(out.choices[0].message.content).toBe('got it');
  });

  it('preserves tool_calls even when text content is array-shaped', () => {
    const chunk = { choices: [{ delta: { content: [{ type: 'text', text: '' }], tool_calls: [{ index: 0, id: 'c1', function: { name: 'f', arguments: '{}' } }] } }] };
    const out = normalizeOutboundContent(chunk);
    expect(out.choices[0].delta.content).toBe('');
    expect(out.choices[0].delta.tool_calls[0].id).toBe('c1');
  });

  it('leaves string content untouched', () => {
    const chunk = { choices: [{ delta: { content: 'already a string' } }] };
    expect(normalizeOutboundContent(chunk).choices[0].delta.content).toBe('already a string');
  });

  it('tolerates chunks with no choices array (usage/keepalive frames)', () => {
    expect(() => normalizeOutboundContent({ usage: { prompt_tokens: 1 } })).not.toThrow();
    expect(() => normalizeOutboundContent(null as unknown)).not.toThrow();
    expect(() => normalizeOutboundContent({} as unknown)).not.toThrow();
  });
});