Skip to content

Commit 4ef9d1c

Browse files
committed
cli: Convert streamed <think> tags to a thinking block
1 parent d13f937 commit 4ef9d1c

File tree

5 files changed

+658
-43
lines changed

5 files changed

+658
-43
lines changed

cli/src/types/chat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type TextContentBlock = {
1515
isCollapsed?: boolean
1616
thinkingId?: string
1717
userOpened?: boolean
18+
/** True if this is a reasoning block from a <think> tag that hasn't been closed yet */
19+
thinkingOpen?: boolean
1820
}
1921
export type HtmlContentBlock = {
2022
type: 'html'

cli/src/utils/__tests__/send-message-helpers.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,166 @@ describe('appendTextToRootStream', () => {
438438

439439
expect(result).toBe(blocks)
440440
})
441+
442+
// Think tag parsing tests
443+
test('parses think tags in text and creates reasoning blocks', () => {
444+
const result = appendTextToRootStream([], {
445+
type: 'text',
446+
text: 'Before <think>My thoughts</think> after',
447+
})
448+
449+
expect(result).toHaveLength(3)
450+
expect((result[0] as any).content).toBe('Before ')
451+
expect((result[0] as any).textType).toBe('text')
452+
expect((result[1] as any).content).toBe('My thoughts')
453+
expect((result[1] as any).textType).toBe('reasoning')
454+
expect((result[1] as any).isCollapsed).toBe(true)
455+
expect((result[2] as any).content).toBe(' after')
456+
expect((result[2] as any).textType).toBe('text')
457+
})
458+
459+
test('handles unclosed think tag', () => {
460+
const result = appendTextToRootStream([], {
461+
type: 'text',
462+
text: 'Before <think>unclosed thoughts',
463+
})
464+
465+
expect(result).toHaveLength(2)
466+
expect((result[0] as any).content).toBe('Before ')
467+
expect((result[1] as any).content).toBe('unclosed thoughts')
468+
expect((result[1] as any).textType).toBe('reasoning')
469+
expect((result[1] as any).thinkingOpen).toBe(true)
470+
})
471+
472+
test('continues appending to open thinking block', () => {
473+
const blocks: ContentBlock[] = [
474+
{
475+
type: 'text',
476+
content: 'initial thoughts',
477+
textType: 'reasoning',
478+
isCollapsed: true,
479+
thinkingOpen: true,
480+
},
481+
]
482+
483+
const result = appendTextToRootStream(blocks, {
484+
type: 'text',
485+
text: ' more thoughts',
486+
})
487+
488+
expect(result).toHaveLength(1)
489+
expect((result[0] as any).content).toBe('initial thoughts more thoughts')
490+
expect((result[0] as any).textType).toBe('reasoning')
491+
})
492+
493+
test('closes thinking block when close tag received', () => {
494+
const blocks: ContentBlock[] = [
495+
{
496+
type: 'text',
497+
content: 'initial thoughts',
498+
textType: 'reasoning',
499+
isCollapsed: true,
500+
thinkingOpen: true,
501+
},
502+
]
503+
504+
const result = appendTextToRootStream(blocks, {
505+
type: 'text',
506+
text: ' final</think> regular text',
507+
})
508+
509+
expect(result).toHaveLength(2)
510+
expect((result[0] as any).content).toBe('initial thoughts final')
511+
expect((result[0] as any).textType).toBe('reasoning')
512+
expect((result[0] as any).thinkingOpen).toBe(false)
513+
expect((result[1] as any).content).toBe(' regular text')
514+
expect((result[1] as any).textType).toBe('text')
515+
})
516+
517+
test('handles multiple think tags in one chunk', () => {
518+
const result = appendTextToRootStream([], {
519+
type: 'text',
520+
text: '<think>first</think> middle <think>second</think>',
521+
})
522+
523+
expect(result).toHaveLength(3)
524+
expect((result[0] as any).textType).toBe('reasoning')
525+
expect((result[0] as any).content).toBe('first')
526+
expect((result[1] as any).textType).toBe('text')
527+
expect((result[1] as any).content).toBe(' middle ')
528+
expect((result[2] as any).textType).toBe('reasoning')
529+
expect((result[2] as any).content).toBe('second')
530+
})
531+
532+
test('handles think tag at start of text', () => {
533+
const result = appendTextToRootStream([], {
534+
type: 'text',
535+
text: '<think>thoughts</think> after',
536+
})
537+
538+
expect(result).toHaveLength(2)
539+
expect((result[0] as any).textType).toBe('reasoning')
540+
expect((result[0] as any).content).toBe('thoughts')
541+
expect((result[1] as any).textType).toBe('text')
542+
expect((result[1] as any).content).toBe(' after')
543+
})
544+
545+
test('text without think tags works normally', () => {
546+
const result = appendTextToRootStream([], {
547+
type: 'text',
548+
text: 'Just regular text without tags',
549+
})
550+
551+
expect(result).toHaveLength(1)
552+
expect((result[0] as any).content).toBe('Just regular text without tags')
553+
expect((result[0] as any).textType).toBe('text')
554+
})
555+
556+
test('closes thinking block when receiving just </think> tag', () => {
557+
const blocks: ContentBlock[] = [
558+
{
559+
type: 'text',
560+
content: 'thoughts',
561+
textType: 'reasoning',
562+
isCollapsed: true,
563+
thinkingOpen: true,
564+
},
565+
]
566+
567+
const result = appendTextToRootStream(blocks, {
568+
type: 'text',
569+
text: '</think>',
570+
})
571+
572+
expect(result).toHaveLength(1)
573+
expect((result[0] as any).content).toBe('thoughts')
574+
expect((result[0] as any).textType).toBe('reasoning')
575+
expect((result[0] as any).thinkingOpen).toBe(false)
576+
})
577+
578+
test('closes thinking block and adds text after </think>', () => {
579+
const blocks: ContentBlock[] = [
580+
{
581+
type: 'text',
582+
content: 'thoughts',
583+
textType: 'reasoning',
584+
isCollapsed: true,
585+
thinkingOpen: true,
586+
},
587+
]
588+
589+
const result = appendTextToRootStream(blocks, {
590+
type: 'text',
591+
text: '</think>after',
592+
})
593+
594+
expect(result).toHaveLength(2)
595+
expect((result[0] as any).content).toBe('thoughts')
596+
expect((result[0] as any).textType).toBe('reasoning')
597+
expect((result[0] as any).thinkingOpen).toBe(false)
598+
expect((result[1] as any).content).toBe('after')
599+
expect((result[1] as any).textType).toBe('text')
600+
})
441601
})
442602

443603
describe('extractPlanFromBuffer', () => {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { describe, test, expect } from 'bun:test'
2+
3+
import {
4+
parseThinkTags,
5+
getPartialTagLength,
6+
THINK_OPEN_TAG,
7+
THINK_CLOSE_TAG,
8+
} from '../think-tag-parser'
9+
10+
describe('parseThinkTags', () => {
11+
test('returns empty array for empty string', () => {
12+
expect(parseThinkTags('')).toEqual([])
13+
})
14+
15+
test('returns single text segment for text without tags', () => {
16+
expect(parseThinkTags('Hello world')).toEqual([
17+
{ type: 'text', content: 'Hello world' },
18+
])
19+
})
20+
21+
test('parses single think tag', () => {
22+
expect(parseThinkTags('<think>My thoughts</think>')).toEqual([
23+
{ type: 'thinking', content: 'My thoughts' },
24+
])
25+
})
26+
27+
test('parses think tag with surrounding text', () => {
28+
expect(parseThinkTags('Before <think>thinking</think> after')).toEqual([
29+
{ type: 'text', content: 'Before ' },
30+
{ type: 'thinking', content: 'thinking' },
31+
{ type: 'text', content: ' after' },
32+
])
33+
})
34+
35+
test('parses multiple think tags', () => {
36+
expect(
37+
parseThinkTags('Start <think>first</think> middle <think>second</think> end'),
38+
).toEqual([
39+
{ type: 'text', content: 'Start ' },
40+
{ type: 'thinking', content: 'first' },
41+
{ type: 'text', content: ' middle ' },
42+
{ type: 'thinking', content: 'second' },
43+
{ type: 'text', content: ' end' },
44+
])
45+
})
46+
47+
test('handles unclosed think tag at end', () => {
48+
expect(parseThinkTags('Before <think>unclosed thinking')).toEqual([
49+
{ type: 'text', content: 'Before ' },
50+
{ type: 'thinking', content: 'unclosed thinking' },
51+
])
52+
})
53+
54+
test('handles think tag at start', () => {
55+
expect(parseThinkTags('<think>thoughts</think> after')).toEqual([
56+
{ type: 'thinking', content: 'thoughts' },
57+
{ type: 'text', content: ' after' },
58+
])
59+
})
60+
61+
test('handles think tag at end', () => {
62+
expect(parseThinkTags('before <think>thoughts</think>')).toEqual([
63+
{ type: 'text', content: 'before ' },
64+
{ type: 'thinking', content: 'thoughts' },
65+
])
66+
})
67+
68+
test('handles empty think tag', () => {
69+
expect(parseThinkTags('before <think></think> after')).toEqual([
70+
{ type: 'text', content: 'before ' },
71+
{ type: 'text', content: ' after' },
72+
])
73+
})
74+
75+
test('handles multiline content in think tag', () => {
76+
const input = 'Before\n<think>Line 1\nLine 2\nLine 3</think>\nAfter'
77+
expect(parseThinkTags(input)).toEqual([
78+
{ type: 'text', content: 'Before\n' },
79+
{ type: 'thinking', content: 'Line 1\nLine 2\nLine 3' },
80+
{ type: 'text', content: '\nAfter' },
81+
])
82+
})
83+
84+
test('handles consecutive think tags', () => {
85+
expect(parseThinkTags('<think>first</think><think>second</think>')).toEqual([
86+
{ type: 'thinking', content: 'first' },
87+
{ type: 'thinking', content: 'second' },
88+
])
89+
})
90+
91+
test('preserves whitespace inside think tags', () => {
92+
expect(parseThinkTags('<think> spaced content </think>')).toEqual([
93+
{ type: 'thinking', content: ' spaced content ' },
94+
])
95+
})
96+
97+
test('handles only opening tag', () => {
98+
expect(parseThinkTags('<think>started thinking')).toEqual([
99+
{ type: 'thinking', content: 'started thinking' },
100+
])
101+
})
102+
})
103+
104+
describe('getPartialTagLength', () => {
105+
test('returns 0 for text without partial tags', () => {
106+
expect(getPartialTagLength('hello world')).toBe(0)
107+
expect(getPartialTagLength('some text')).toBe(0)
108+
expect(getPartialTagLength('')).toBe(0)
109+
})
110+
111+
test('detects partial opening tag prefixes', () => {
112+
expect(getPartialTagLength('text<')).toBe(1)
113+
expect(getPartialTagLength('text<t')).toBe(2)
114+
expect(getPartialTagLength('text<th')).toBe(3)
115+
expect(getPartialTagLength('text<thi')).toBe(4)
116+
expect(getPartialTagLength('text<thin')).toBe(5)
117+
expect(getPartialTagLength('text<think')).toBe(6)
118+
})
119+
120+
test('detects partial closing tag prefixes', () => {
121+
expect(getPartialTagLength('text</')).toBe(2)
122+
expect(getPartialTagLength('text</t')).toBe(3)
123+
expect(getPartialTagLength('text</th')).toBe(4)
124+
expect(getPartialTagLength('text</thi')).toBe(5)
125+
expect(getPartialTagLength('text</thin')).toBe(6)
126+
expect(getPartialTagLength('text</think')).toBe(7)
127+
})
128+
129+
test('returns 0 for complete tags', () => {
130+
expect(getPartialTagLength('text<think>')).toBe(0)
131+
expect(getPartialTagLength('text</think>')).toBe(0)
132+
})
133+
134+
test('returns 0 for non-tag < character', () => {
135+
expect(getPartialTagLength('text<x')).toBe(0)
136+
expect(getPartialTagLength('text<a')).toBe(0)
137+
expect(getPartialTagLength('text</x')).toBe(0)
138+
})
139+
140+
test('handles just the partial tag character', () => {
141+
expect(getPartialTagLength('<')).toBe(1)
142+
expect(getPartialTagLength('<t')).toBe(2)
143+
expect(getPartialTagLength('</')).toBe(2)
144+
})
145+
})
146+
147+
describe('tag constants', () => {
148+
test('THINK_OPEN_TAG is correct', () => {
149+
expect(THINK_OPEN_TAG).toBe('<think>')
150+
})
151+
152+
test('THINK_CLOSE_TAG is correct', () => {
153+
expect(THINK_CLOSE_TAG).toBe('</think>')
154+
})
155+
})

0 commit comments

Comments
 (0)