Skip to content

Commit 81e91fc

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@fd688f5473c00425188f708047e3ab8b2cefe9f8
1 parent d4b3695 commit 81e91fc

5 files changed

Lines changed: 743 additions & 2 deletions

File tree

freebuff/cli/release/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "freebuff",
3-
"version": "0.0.158",
3+
"version": "0.0.159",
44
"description": "The world's strongest free coding agent",
55
"license": "MIT",
66
"bin": {
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* End-to-end over processStream: a lane that puts a thinking model's chain of
3+
* thought in `content` instead of `reasoning_content` must not reach a surface
4+
* as prose, and must never reach one carrying a literal `</think>`.
5+
*/
6+
import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime'
7+
import { getInitialSessionState } from '@codebuff/common/types/session-state'
8+
import { beforeEach, describe, expect, it } from 'bun:test'
9+
10+
import { mockFileContext } from './test-utils'
11+
import { processStream } from '../tools/stream-parser'
12+
13+
import type { AgentTemplate } from '../templates/types'
14+
import type {
15+
AgentRuntimeDeps,
16+
AgentRuntimeScopedDeps,
17+
} from '@codebuff/common/types/contracts/agent-runtime'
18+
import type { StreamChunk } from '@codebuff/common/types/contracts/llm'
19+
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
20+
import type { PrintModeEvent } from '@codebuff/common/types/print-mode'
21+
import type { PromptResult } from '@codebuff/common/util/error'
22+
23+
const testAgentTemplate: AgentTemplate = {
24+
id: 'test-agent',
25+
displayName: 'Test Agent',
26+
spawnerPrompt: 'Test agent',
27+
model: 'deepseek/deepseek-v4-flash',
28+
inputSchema: {},
29+
outputMode: 'structured_output',
30+
includeMessageHistory: true,
31+
inheritParentSystemPrompt: false,
32+
mcpServers: {},
33+
toolNames: ['read_files', 'end_turn'],
34+
spawnableAgents: [],
35+
systemPrompt: 'Test system prompt',
36+
instructionsPrompt: 'Test instructions',
37+
stepPrompt: 'Test step prompt',
38+
}
39+
40+
/** What a surface would render: streamed text, and the thinking box. */
41+
interface Rendered {
42+
text: string
43+
reasoning: string
44+
fullResponse: string
45+
}
46+
47+
describe('processStream — leaked think tags', () => {
48+
let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps
49+
50+
beforeEach(() => {
51+
agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL, sendAction: () => {} }
52+
})
53+
54+
async function render(
55+
chunks: StreamChunk[],
56+
priorHistory: Message[] = [],
57+
): Promise<Rendered> {
58+
async function* stream(): AsyncGenerator<
59+
StreamChunk,
60+
PromptResult<string | null>
61+
> {
62+
for (const chunk of chunks) yield chunk
63+
return { aborted: false, value: 'msg-id' }
64+
}
65+
66+
const sessionState = getInitialSessionState(mockFileContext)
67+
const agentState = sessionState.mainAgentState
68+
agentState.messageHistory = [...priorHistory]
69+
70+
let text = ''
71+
let reasoning = ''
72+
const result = await processStream({
73+
...agentRuntimeImpl,
74+
agentContext: {},
75+
agentState,
76+
agentStepId: 'test-step-id',
77+
agentTemplate: testAgentTemplate,
78+
ancestorRunIds: [],
79+
clientSessionId: 'test-session',
80+
fileContext: mockFileContext,
81+
fingerprintId: 'test-fingerprint',
82+
fullResponse: '',
83+
localAgentTemplates: { 'test-agent': testAgentTemplate },
84+
messages: agentState.messageHistory,
85+
prompt: 'test prompt',
86+
repoId: undefined,
87+
repoUrl: undefined,
88+
runId: 'test-run-id',
89+
signal: new AbortController().signal,
90+
stream: stream(),
91+
system: 'test system',
92+
tools: {},
93+
userId: 'test-user',
94+
userInputId: 'test-input-id',
95+
onCostCalculated: async () => {},
96+
onResponseChunk: (chunk: string | PrintModeEvent) => {
97+
if (typeof chunk === 'string') {
98+
text += chunk
99+
} else if (chunk.type === 'reasoning_delta') {
100+
reasoning += chunk.text
101+
}
102+
},
103+
})
104+
105+
return { text, reasoning, fullResponse: result.fullResponse }
106+
}
107+
108+
const textChunks = (...texts: string[]): StreamChunk[] =>
109+
texts.map((t) => ({ type: 'text' as const, text: t }))
110+
111+
const leakedAssistantTurn: Message[] = [
112+
{
113+
role: 'assistant',
114+
content: [{ type: 'text', text: 'earlier thought</think>earlier answer' }],
115+
} as Message,
116+
]
117+
118+
it('routes a paired block to the thinking box', async () => {
119+
const { text, reasoning } = await render(
120+
textChunks('<think>weigh the options</think>', 'The answer.'),
121+
)
122+
expect(reasoning).toBe('weigh the options')
123+
expect(text).toBe('The answer.')
124+
})
125+
126+
it('never streams a bare close marker, even unarmed', async () => {
127+
const { text, reasoning } = await render(
128+
textChunks('Saw the anchor.', '</think>', 'Now the fix.'),
129+
)
130+
expect(text).toBe('Saw the anchor.Now the fix.')
131+
expect(text).not.toContain('</think>')
132+
expect(reasoning).toBe('')
133+
})
134+
135+
it('reclassifies the head once a prior turn proved the lane leaks', async () => {
136+
const { text, reasoning } = await render(
137+
textChunks('Ключевая зацепка: ', 'the bundle knows.', '</think>Real answer.'),
138+
leakedAssistantTurn,
139+
)
140+
expect(reasoning).toBe('Ключевая зацепка: the bundle knows.')
141+
expect(text).toBe('Real answer.')
142+
})
143+
144+
it('releases the head as text when the marker never comes', async () => {
145+
// The lossless guarantee: a wrong guess delays an answer, never hides it.
146+
const { text, reasoning } = await render(
147+
textChunks('A clean answer this time.'),
148+
leakedAssistantTurn,
149+
)
150+
expect(text).toBe('A clean answer this time.')
151+
expect(reasoning).toBe('')
152+
})
153+
154+
it('stands down when the lane does populate native reasoning', async () => {
155+
const { text, reasoning } = await render(
156+
[
157+
{ type: 'reasoning', text: 'native thought' },
158+
...textChunks('The answer.'),
159+
],
160+
leakedAssistantTurn,
161+
)
162+
expect(reasoning).toBe('native thought')
163+
expect(text).toBe('The answer.')
164+
})
165+
166+
it('leaves fullResponse raw so the turn-end and arming signals survive', async () => {
167+
// isThinkOnlyResponse reads fullResponse to keep a thinking-only step from
168+
// ending the turn; historyLeaksThinkTags reads the history to arm the next.
169+
const { fullResponse } = await render(textChunks('thought', '</think>done'))
170+
expect(fullResponse).toBe('thought</think>done')
171+
})
172+
})

packages/agent-runtime/src/tools/stream-parser.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ import {
1717
tryTransformAgentToolCall,
1818
} from './tool-executor'
1919
import { withSystemTags } from '../util/messages'
20+
import {
21+
historyLeaksThinkTags,
22+
stripThinkScaffolding,
23+
ThinkTagStream,
24+
} from '../util/think-tag-stream'
2025

2126
import type { CustomToolCall, ExecuteToolCallParams } from './tool-executor'
27+
import type { ThinkStreamSegment } from '../util/think-tag-stream'
2228
import type { AgentTemplate } from '../templates/types'
2329
import type { FileProcessingState } from './handlers/tool/write-file'
2430
import type { ToolName } from '@codebuff/common/tools/constants'
@@ -170,6 +176,31 @@ export async function processStream(
170176
} = params
171177
const fullResponseChunks: string[] = [fullResponse]
172178

179+
// === LEAKED-REASONING SPLIT ===
180+
// Reasoning that a lane failed to put in its native field arrives here as
181+
// ordinary text, tags and all. Reclassify it before it reaches a surface, so
182+
// the thinking box is the only place a chain of thought is ever rendered.
183+
// See util/think-tag-stream.ts for the three shapes and why the implicit-open
184+
// rule is armed from the history rather than from a model id.
185+
const thinkTagStream = new ThinkTagStream({
186+
implicitOpen: historyLeaksThinkTags(agentState.messageHistory),
187+
})
188+
const emitThinkSegments = (segments: ThinkStreamSegment[]): void => {
189+
for (const segment of segments) {
190+
if (segment.type === 'text') {
191+
onResponseChunk(segment.text)
192+
} else {
193+
onResponseChunk({
194+
type: 'reasoning_delta',
195+
text: segment.text,
196+
ancestorRunIds,
197+
runId,
198+
agentId: agentState.agentId,
199+
})
200+
}
201+
}
202+
}
203+
173204
// === MUTABLE STATE ===
174205
const toolResults: ToolMessage[] = []
175206
const toolResultsToAddToMessageHistory: ToolMessage[] = []
@@ -358,8 +389,19 @@ export async function processStream(
358389
onResponseChunk: (chunk) => {
359390
if (chunk.type === 'text') {
360391
if (chunk.text) {
392+
// Raw, like fullResponse: the history is where the leak evidence and
393+
// the thinking-only turn-end signal live.
361394
assistantMessages.push(assistantMessage(chunk.text))
362395
}
396+
// This event is the CONSOLIDATED fallback for consumers that did not
397+
// take the streamed deltas — the desktop harness renders it only when
398+
// nothing streamed, which is exactly the thinking-only step where every
399+
// delta above went to the thinking box instead. Forwarding it raw would
400+
// put the scaffolding back on screen through the side door.
401+
const visible = stripThinkScaffolding(chunk.text)
402+
if (visible !== chunk.text) {
403+
return onResponseChunk({ ...chunk, text: visible })
404+
}
363405
} else if (chunk.type === 'error') {
364406
// do nothing
365407
} else {
@@ -433,6 +475,10 @@ export async function processStream(
433475
}
434476
}
435477
if (chunk.text) {
478+
// A lane that populates the native reasoning field is by definition
479+
// not putting the thought in `content`, so this step is not leaking
480+
// and anything held on speculation is the answer.
481+
emitThinkSegments(thinkTagStream.disarmImplicitOpen())
436482
onResponseChunk({
437483
type: 'reasoning_delta',
438484
text: chunk.text,
@@ -442,7 +488,11 @@ export async function processStream(
442488
})
443489
}
444490
} else if (chunk.type === 'text') {
445-
onResponseChunk(chunk.text)
491+
// Deliberately the RAW text into fullResponse and the message history:
492+
// isThinkOnlyResponse reads it to keep a thinking-only step from ending
493+
// the turn, and historyLeaksThinkTags reads it to arm the next step.
494+
// Stripping it here would erase both signals.
495+
emitThinkSegments(thinkTagStream.push(chunk.text))
446496
fullResponseChunks.push(chunk.text)
447497
} else if (chunk.type === 'error') {
448498
onResponseChunk(chunk)
@@ -503,6 +553,12 @@ export async function processStream(
503553
}
504554
}
505555

556+
// The step's content is complete: release anything the split still holds,
557+
// before the tool-completion await below, so a released head keeps its
558+
// place ahead of this step's tool events. `flush` is idempotent, so the
559+
// safety-net call in `finally` is a no-op on this path.
560+
emitThinkSegments(thinkTagStream.flush())
561+
506562
// Retry-outcome signal: this step streamed to completion (no new
507563
// recovery, no user abort) while the history tail still carries a
508564
// recovery streak — meaning the forced-step retry rescued the turn.
@@ -541,6 +597,11 @@ export async function processStream(
541597
}
542598
} finally {
543599
// === FINALIZATION ===
600+
// Release anything the think-tag split is still holding — a partial tag
601+
// that never completed, or a head held for an orphan `</think>` that never
602+
// came. Runs on the abort paths too, so speculation can never lose text.
603+
emitThinkSegments(thinkTagStream.flush())
604+
544605
// Trigger cleanup of the processStreamWithTools generator so it flushes any
545606
// remaining buffered text to assistantMessages before we build the history.
546607
// On path B (AbortError thrown mid-stream) the generator is already completed

0 commit comments

Comments
 (0)