Skip to content

Commit 4104b18

Browse files
Sync public snapshot from freebuff-private
1 parent 623c45d commit 4104b18

6 files changed

Lines changed: 237 additions & 57 deletions

File tree

‎bun.lock‎

Lines changed: 47 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { appendTextToRootStream } from '../block-operations'
4+
5+
import type { ContentBlock, TextContentBlock } from '../../types/chat'
6+
7+
const stream = (text: string, chunkSize: number): ContentBlock[] => {
8+
let blocks: ContentBlock[] = []
9+
for (let i = 0; i < text.length; i += chunkSize) {
10+
blocks = appendTextToRootStream(blocks, {
11+
type: 'text',
12+
text: text.slice(i, i + chunkSize),
13+
})
14+
}
15+
return blocks
16+
}
17+
18+
// thinkingId is a global counter, so compare everything else.
19+
const shape = (blocks: ContentBlock[]) =>
20+
blocks.map((block) => {
21+
const { content, textType, thinkingOpen } = block as TextContentBlock
22+
return { type: block.type, content, textType, thinkingOpen }
23+
})
24+
25+
describe('appendTextToRootStream seam scan', () => {
26+
const corpus = [
27+
'plain text with no tags at all',
28+
'before<think>thought</think>after',
29+
'<think>a</think><think>b</think>c',
30+
'code: if (a < b && c > d) { return <Item value={a} /> }',
31+
'stray </think> close then more',
32+
'a <thinker> is not a tag, nor is </thin k>',
33+
`${'x'.repeat(5_000)}<think>late</think>tail`,
34+
'<think></think>',
35+
'open <think>never closed',
36+
]
37+
38+
test('chunked deltas produce the blocks of a single append', () => {
39+
for (const text of corpus) {
40+
const whole = shape(stream(text, text.length))
41+
for (const chunkSize of [7, 8, 9, 64]) {
42+
expect({ text, chunkSize, blocks: shape(stream(text, chunkSize)) })
43+
.toEqual({ text, chunkSize, blocks: whole })
44+
}
45+
}
46+
})
47+
48+
test('a tag split at every position after a long block opens thinking', () => {
49+
const prefix = 'p'.repeat(50_000)
50+
for (let split = 1; split < '<think>'.length; split++) {
51+
let blocks = appendTextToRootStream([], {
52+
type: 'text',
53+
text: prefix + '<think>'.slice(0, split),
54+
})
55+
blocks = appendTextToRootStream(blocks, {
56+
type: 'text',
57+
text: '<think>'.slice(split) + 'body',
58+
})
59+
expect(shape(blocks)).toEqual([
60+
{ type: 'text', content: prefix, textType: 'text', thinkingOpen: undefined },
61+
{ type: 'text', content: 'body', textType: 'reasoning', thinkingOpen: true },
62+
])
63+
}
64+
})
65+
66+
test('a long reply full of less-than signs stays one text block', () => {
67+
const line = 'if (a < b) { return <Row key={i} /> }\n'
68+
const text = line.repeat(500)
69+
const blocks = stream(text, 13)
70+
expect(shape(blocks)).toEqual([
71+
{ type: 'text', content: text, textType: 'text', thinkingOpen: undefined },
72+
])
73+
})
74+
})

‎cli/src/utils/__tests__/message-updater.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,66 @@ describe('createBatchedMessageUpdater timer behavior', () => {
643643
expect(intervalCallbacks.size).toBe(0)
644644
})
645645
})
646+
647+
describe('appendRootText', () => {
648+
const shape = (blocks: ContentBlock[] | undefined) =>
649+
(blocks ?? []).map((block) => {
650+
const { content, textType, thinkingOpen } = block as TextContentBlock
651+
return { type: block.type, content, textType, thinkingOpen }
652+
})
653+
654+
const deltas = [
655+
{ type: 'text' as const, text: 'answer ' },
656+
{ type: 'text' as const, text: 'with <think>a tagged' },
657+
{ type: 'text' as const, text: ' thought</think> and ' },
658+
{ type: 'reasoning' as const, text: 'native reasoning' },
659+
{ type: 'reasoning' as const, text: ' continues' },
660+
{ type: 'text' as const, text: 'then text' },
661+
]
662+
663+
test('a batched flush matches applying each delta immediately', () => {
664+
let immediate: ChatMessage[] = [{ ...baseMessages[0] }]
665+
const plain = createMessageUpdater('ai-1', (fn) => {
666+
immediate = fn(immediate)
667+
})
668+
for (const delta of deltas) plain.appendRootText(delta)
669+
670+
let batchedState: ChatMessage[] = [{ ...baseMessages[0] }]
671+
let setCalls = 0
672+
const batched = createBatchedMessageUpdater(
673+
'ai-1',
674+
(fn) => {
675+
setCalls++
676+
batchedState = fn(batchedState)
677+
},
678+
60_000,
679+
)
680+
for (const delta of deltas) batched.appendRootText(delta)
681+
batched.flush()
682+
batched.dispose()
683+
684+
expect(setCalls).toBe(1)
685+
expect(shape(batchedState[0].blocks)).toEqual(shape(immediate[0].blocks))
686+
})
687+
688+
test('other updates keep their place between text deltas', () => {
689+
let state: ChatMessage[] = [{ ...baseMessages[0] }]
690+
const batched = createBatchedMessageUpdater(
691+
'ai-1',
692+
(fn) => {
693+
state = fn(state)
694+
},
695+
60_000,
696+
)
697+
batched.appendRootText({ type: 'text', text: 'before ' })
698+
batched.appendRootText({ type: 'text', text: 'tool' })
699+
batched.addBlock({ type: 'text', content: 'TOOL', textType: 'reasoning' })
700+
batched.appendRootText({ type: 'text', text: 'after' })
701+
batched.flush()
702+
batched.dispose()
703+
704+
expect(
705+
(state[0].blocks ?? []).map((b) => (b as TextContentBlock).content),
706+
).toEqual(['before tool', 'TOOL', 'after'])
707+
})
708+
})

‎cli/src/utils/block-operations.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,14 @@ const appendTextWithThinkParsingToBlocks = (
170170
lastBlock?.type === 'text' &&
171171
lastBlock.textType === 'text'
172172
) {
173-
const combinedText = lastBlock.content + text
174-
175-
if (
176-
combinedText.includes(THINK_OPEN_TAG) ||
177-
combinedText.includes(THINK_CLOSE_TAG)
178-
) {
179-
textToParse = combinedText
173+
// A plain text block never holds a whole tag (parsing consumed it), so a
174+
// tag can only be completed across the seam: the block's last
175+
// tag-length-minus-one characters plus this delta. Scanning just that keeps
176+
// each delta O(delta) instead of O(block), which was quadratic over a long reply.
177+
const seam = lastBlock.content.slice(-(THINK_CLOSE_TAG.length - 1)) + text
178+
179+
if (seam.includes(THINK_OPEN_TAG) || seam.includes(THINK_CLOSE_TAG)) {
180+
textToParse = lastBlock.content + text
180181
nextBlocks.pop()
181182
}
182183
}

‎cli/src/utils/message-updater.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { appendTextToRootStream } from './block-operations'
2+
13
import type { ChatMessage, ContentBlock, TextContentBlock } from '../types/chat'
24

5+
export type RootTextDelta = { type: 'text' | 'reasoning'; text: string }
6+
37
// Small wrapper to avoid repeating the ai-message map/update pattern.
48
export type SetMessagesFn = (
59
updater: (messages: ChatMessage[]) => ChatMessage[],
@@ -10,6 +14,9 @@ export type MessageUpdater = {
1014
updateAiMessageBlocks: (
1115
blockUpdater: (blocks: ContentBlock[]) => ContentBlock[],
1216
) => void
17+
/** Append a streamed delta to the root message's blocks. The batched
18+
* updater merges consecutive deltas of one type into a single append. */
19+
appendRootText: (delta: RootTextDelta) => void
1320
markComplete: (metadata?: Partial<ChatMessage>) => void
1421
setError: (message: string) => void
1522
/** Clears the userError field (e.g., when a new message is sent successfully) */
@@ -47,6 +54,10 @@ export const createMessageUpdater = (
4754
}))
4855
}
4956

57+
const appendRootText = (delta: RootTextDelta) => {
58+
updateAiMessageBlocks((blocks) => appendTextToRootStream(blocks, delta))
59+
}
60+
5061
const addBlock = (block: ContentBlock) => {
5162
updateAiMessage((msg) => ({
5263
...msg,
@@ -113,6 +124,7 @@ export const createMessageUpdater = (
113124
return {
114125
updateAiMessage,
115126
updateAiMessageBlocks,
127+
appendRootText,
116128
markComplete,
117129
setError,
118130
clearUserError,
@@ -126,8 +138,13 @@ export const createBatchedMessageUpdater = (
126138
setMessages: SetMessagesFn,
127139
flushIntervalMs: number = DEFAULT_FLUSH_INTERVAL_MS,
128140
): BatchedMessageUpdater => {
129-
// Queue of message updater functions to be applied on next flush
130-
const pendingUpdaters: Array<(msg: ChatMessage) => ChatMessage> = []
141+
// Queue of message updater functions to be applied on next flush. A root
142+
// text append stays a delta, not a closure, until the flush so the next
143+
// delta of the same type can extend it: one block-array copy per flush
144+
// instead of one per token.
145+
const pendingUpdaters: Array<
146+
((msg: ChatMessage) => ChatMessage) | RootTextDelta
147+
> = []
131148
let intervalId: ReturnType<typeof setInterval> | null = null
132149
let isDisposed = false
133150

@@ -139,7 +156,13 @@ export const createBatchedMessageUpdater = (
139156

140157
// Compose all pending updaters into a single transform
141158
const composedUpdater = (msg: ChatMessage): ChatMessage => {
142-
return updaters.reduce((m, fn) => fn(m), msg)
159+
return updaters.reduce(
160+
(m, entry) =>
161+
typeof entry === 'function'
162+
? entry(m)
163+
: { ...m, blocks: appendTextToRootStream(m.blocks ?? [], entry) },
164+
msg,
165+
)
143166
}
144167

145168
// Apply composed update to the target message
@@ -182,6 +205,23 @@ export const createBatchedMessageUpdater = (
182205
}))
183206
}
184207

208+
const appendRootText = (delta: RootTextDelta) => {
209+
if (!delta.text) return
210+
if (isDisposed) {
211+
updateAiMessageBlocks((blocks) => appendTextToRootStream(blocks, delta))
212+
return
213+
}
214+
const last = pendingUpdaters[pendingUpdaters.length - 1]
215+
if (last && typeof last !== 'function' && last.type === delta.type) {
216+
pendingUpdaters[pendingUpdaters.length - 1] = {
217+
type: last.type,
218+
text: last.text + delta.text,
219+
}
220+
return
221+
}
222+
pendingUpdaters.push({ type: delta.type, text: delta.text })
223+
}
224+
185225
const addBlock = (block: ContentBlock) => {
186226
updateAiMessage((msg) => ({
187227
...msg,
@@ -269,6 +309,7 @@ export const createBatchedMessageUpdater = (
269309
return {
270310
updateAiMessage,
271311
updateAiMessageBlocks,
312+
appendRootText,
272313
markComplete,
273314
setError,
274315
clearUserError,

‎cli/src/utils/sdk-event-handlers.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from '../ads/ad-signals'
88

99
import {
10-
appendTextToRootStream,
1110
appendToolToAgentBlock,
1211
closeNativeReasoningBlock,
1312
closeNativeReasoningInAgent,
@@ -131,9 +130,7 @@ const appendRootChunk = (state: EventHandlerState, delta: TextDelta) => {
131130
return
132131
}
133132

134-
state.message.updater.updateAiMessageBlocks((blocks) =>
135-
appendTextToRootStream(blocks, delta),
136-
)
133+
state.message.updater.appendRootText(delta)
137134

138135
if (
139136
state.mode.agentMode === 'PLAN' &&

0 commit comments

Comments
 (0)