Skip to content

Commit 72490f3

Browse files
committed
Emit buffered text mid-run and add tests
1 parent 22bcb46 commit 72490f3

File tree

2 files changed

+454
-38
lines changed

2 files changed

+454
-38
lines changed
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import {
2+
afterAll,
3+
beforeAll,
4+
beforeEach,
5+
describe,
6+
expect,
7+
test,
8+
} from 'bun:test'
9+
10+
import {
11+
clearMockedModules,
12+
mockModule,
13+
} from '../../../common/src/testing/mock-modules'
14+
import {
15+
getInitialAgentState,
16+
getInitialSessionState,
17+
} from '../../../common/src/types/session-state'
18+
import { getStubProjectFileContext } from '../../../common/src/util/file'
19+
20+
import type { PrintModeEvent } from '../../../common/src/types/print-mode'
21+
import type { ClientAction, ServerAction } from '../../../common/src/actions'
22+
23+
type MockHandlerInstance = {
24+
options: {
25+
onResponseChunk: (action: ServerAction<'response-chunk'>) => Promise<void>
26+
onPromptResponse: (
27+
action: ServerAction<'prompt-response'>,
28+
) => Promise<void>
29+
}
30+
lastInput?: ClientAction<'prompt'>
31+
}
32+
33+
const handlerState: { instances: MockHandlerInstance[] } = {
34+
instances: [],
35+
}
36+
37+
const createSessionState = () => {
38+
const sessionState = getInitialSessionState(getStubProjectFileContext())
39+
sessionState.mainAgentState = {
40+
...getInitialAgentState(),
41+
agentType: 'base',
42+
}
43+
return sessionState
44+
}
45+
46+
let run: typeof import('../run').run
47+
48+
beforeAll(async () => {
49+
await mockModule('../../../sdk/src/websocket-client', () => {
50+
class MockWebSocketHandler {
51+
options: MockHandlerInstance['options']
52+
lastInput?: ClientAction<'prompt'>
53+
54+
constructor(options: MockHandlerInstance['options']) {
55+
this.options = options
56+
handlerState.instances.push(this)
57+
}
58+
59+
async connect(): Promise<void> {}
60+
61+
reconnect(): void {}
62+
63+
close(): void {}
64+
65+
getConnectionStatus(): boolean {
66+
return true
67+
}
68+
69+
getReadyState(): number {
70+
return 1
71+
}
72+
73+
sendInput(input: ClientAction<'prompt'>): void {
74+
this.lastInput = input
75+
}
76+
77+
cancelInput(): void {}
78+
}
79+
80+
return {
81+
WebSocketHandler: MockWebSocketHandler,
82+
}
83+
})
84+
85+
await mockModule('../../../sdk/src/run-state', () => ({
86+
initialSessionState: async () => createSessionState(),
87+
applyOverridesToSessionState: async () => createSessionState(),
88+
}))
89+
90+
;({ run } = await import('../run'))
91+
})
92+
93+
afterAll(() => {
94+
clearMockedModules()
95+
})
96+
97+
beforeEach(() => {
98+
handlerState.instances.splice(0, handlerState.instances.length)
99+
})
100+
101+
const waitForHandler = async (): Promise<MockHandlerInstance> => {
102+
for (let attempt = 0; attempt < 20; attempt++) {
103+
const handler = handlerState.instances.at(-1)
104+
if (handler) {
105+
return handler
106+
}
107+
await new Promise((resolve) => setTimeout(resolve, 0))
108+
}
109+
throw new Error('Mock WebSocketHandler was not instantiated')
110+
}
111+
112+
const resolvePrompt = async (
113+
handler: MockHandlerInstance,
114+
extras: Partial<ClientAction<'prompt'>> = {},
115+
) => {
116+
const promptId =
117+
handler.lastInput?.promptId ??
118+
(typeof crypto !== 'undefined'
119+
? crypto.randomUUID()
120+
: Math.random().toString(36).slice(2))
121+
122+
await handler.options.onPromptResponse({
123+
type: 'prompt-response',
124+
promptId,
125+
sessionState: createSessionState(),
126+
toolCalls: [],
127+
toolResults: [],
128+
output: {
129+
type: 'lastMessage',
130+
value: null,
131+
},
132+
...extras,
133+
})
134+
}
135+
136+
const responseChunk = (
137+
handler: MockHandlerInstance,
138+
chunk: ServerAction<'response-chunk'>['chunk'],
139+
): ServerAction<'response-chunk'> => ({
140+
type: 'response-chunk',
141+
userInputId: handler.lastInput?.promptId ?? 'prompt',
142+
chunk,
143+
})
144+
145+
describe('run() text emission', () => {
146+
const baseRunOptions = {
147+
apiKey: 'test-key',
148+
fingerprintId: 'fp-123',
149+
agent: 'base',
150+
prompt: 'Hello',
151+
cwd: process.cwd(),
152+
} as const
153+
154+
test('emits full root section when string chunks flush on finish', async () => {
155+
const events: PrintModeEvent[] = []
156+
const runPromise = run({
157+
...baseRunOptions,
158+
handleEvent: (event) => {
159+
events.push(event)
160+
},
161+
})
162+
163+
const handler = await waitForHandler()
164+
165+
await handler.options.onResponseChunk(
166+
responseChunk(handler, 'Bootstrapping '),
167+
)
168+
await handler.options.onResponseChunk(
169+
responseChunk(handler, 'stream output\nNext line.'),
170+
)
171+
await handler.options.onResponseChunk(
172+
responseChunk(handler, {
173+
type: 'finish',
174+
totalCost: 0,
175+
}),
176+
)
177+
178+
await resolvePrompt(handler)
179+
await runPromise
180+
181+
const textEvents = events.filter(
182+
(event): event is PrintModeEvent & { type: 'text' } =>
183+
event.type === 'text',
184+
)
185+
expect(textEvents).toHaveLength(1)
186+
expect(textEvents[0]).toMatchObject({
187+
type: 'text',
188+
text: 'Bootstrapping stream output\nNext line.',
189+
})
190+
})
191+
192+
test('splits root sections around tool events without duplication', async () => {
193+
const events: PrintModeEvent[] = []
194+
const runPromise = run({
195+
...baseRunOptions,
196+
handleEvent: async (event) => {
197+
events.push(event)
198+
},
199+
})
200+
201+
const handler = await waitForHandler()
202+
203+
await handler.options.onResponseChunk(
204+
responseChunk(handler, {
205+
type: 'text',
206+
text: 'First section',
207+
}),
208+
)
209+
await handler.options.onResponseChunk(
210+
responseChunk(handler, {
211+
type: 'tool_call',
212+
toolCallId: 'tool-1',
213+
toolName: 'example_tool',
214+
input: {},
215+
}),
216+
)
217+
await handler.options.onResponseChunk(
218+
responseChunk(handler, {
219+
type: 'text',
220+
text: 'Second section',
221+
}),
222+
)
223+
await handler.options.onResponseChunk(
224+
responseChunk(handler, {
225+
type: 'finish',
226+
totalCost: 0,
227+
}),
228+
)
229+
230+
await resolvePrompt(handler)
231+
await runPromise
232+
233+
const textEvents = events.filter(
234+
(event): event is PrintModeEvent & { type: 'text' } =>
235+
event.type === 'text',
236+
)
237+
expect(textEvents).toEqual([
238+
expect.objectContaining({ type: 'text', text: 'First section' }),
239+
expect.objectContaining({ type: 'text', text: 'Second section' }),
240+
])
241+
})
242+
243+
test('preserves agent identifiers when emitting sections', async () => {
244+
const events: PrintModeEvent[] = []
245+
const runPromise = run({
246+
...baseRunOptions,
247+
handleEvent: (event) => {
248+
events.push(event)
249+
},
250+
})
251+
252+
const handler = await waitForHandler()
253+
254+
await handler.options.onResponseChunk(
255+
responseChunk(handler, {
256+
type: 'text',
257+
agentId: 'agent-1',
258+
text: 'Agent text content',
259+
}),
260+
)
261+
await handler.options.onResponseChunk(
262+
responseChunk(handler, {
263+
type: 'subagent_finish',
264+
agentId: 'agent-1',
265+
agentType: 'helper',
266+
displayName: 'Helper',
267+
onlyChild: false,
268+
}),
269+
)
270+
await handler.options.onResponseChunk(
271+
responseChunk(handler, {
272+
type: 'finish',
273+
totalCost: 0,
274+
}),
275+
)
276+
277+
await resolvePrompt(handler)
278+
await runPromise
279+
280+
const textEvents = events.filter(
281+
(event): event is PrintModeEvent & { type: 'text' } =>
282+
event.type === 'text',
283+
)
284+
285+
expect(textEvents).toContainEqual(
286+
expect.objectContaining({
287+
type: 'text',
288+
agentId: 'agent-1',
289+
text: 'Agent text content',
290+
}),
291+
)
292+
})
293+
294+
test('filters tool XML payloads while emitting surrounding text', async () => {
295+
const events: PrintModeEvent[] = []
296+
const runPromise = run({
297+
...baseRunOptions,
298+
handleEvent: (event) => {
299+
events.push(event)
300+
},
301+
})
302+
303+
const handler = await waitForHandler()
304+
305+
await handler.options.onResponseChunk(
306+
responseChunk(handler, {
307+
type: 'text',
308+
text: 'Before <codebuff_tool_call>{"a":1}',
309+
}),
310+
)
311+
await handler.options.onResponseChunk(
312+
responseChunk(handler, {
313+
type: 'text',
314+
text: '</codebuff_tool_call>',
315+
}),
316+
)
317+
await handler.options.onResponseChunk(
318+
responseChunk(handler, {
319+
type: 'text',
320+
text: ' after',
321+
}),
322+
)
323+
await handler.options.onResponseChunk(
324+
responseChunk(handler, {
325+
type: 'finish',
326+
totalCost: 0,
327+
}),
328+
)
329+
330+
await resolvePrompt(handler)
331+
await runPromise
332+
333+
const textEvents = events.filter(
334+
(event): event is PrintModeEvent & { type: 'text' } =>
335+
event.type === 'text',
336+
)
337+
338+
expect(textEvents.map((event) => event.text)).toEqual([
339+
'Before ',
340+
' after',
341+
])
342+
})
343+
})

0 commit comments

Comments
 (0)