Skip to content

Commit 2d857df

Browse files
TiaraBasoriawa
andcommitted
test(proxy): regression test for streaming truncation during internal tool call
Reproduces issue #3 by mocking a backend event stream that emits a planning text, an intermediate finish='stop' snapshot with a pending internal tool call, then the final answer after the tool completes. Without the collectFromEvents fix the stream resolves on the intermediate stop and only the planning text is delivered; with the fix the full answer streams. Co-Authored-By: awa <awa@local>
1 parent 1230924 commit 2d857df

1 file changed

Lines changed: 75 additions & 0 deletions

File tree

‎tests/app.test.js‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,81 @@ describe('Proxy OpenAI API', () => {
779779
expect(res.text).toContain('data: [DONE]');
780780
});
781781

782+
test('POST /v1/chat/completions streaming waits for internal tool execution instead of truncating', async () => {
783+
// Regression test for issue #3: a streaming response that triggers an internal tool
784+
// call (e.g. web_fetch) was truncated at the planning text. The collector resolved on
785+
// an intermediate 'stop' snapshot (or idle-timed-out) while the tool was still running,
786+
// so the final answer was dropped. The stream must stay open until the answer arrives.
787+
sdkMocks.eventSubscribe.mockImplementationOnce(async () => {
788+
const sessionId = 'test-session-id';
789+
const mockEvents = [
790+
{
791+
type: 'message.part.updated',
792+
properties: { part: { type: 'text', sessionID: sessionId }, delta: 'I need to search. ' }
793+
},
794+
{
795+
type: 'message.updated',
796+
properties: {
797+
info: {
798+
sessionID: sessionId,
799+
finish: 'stop',
800+
parts: [
801+
{ type: 'text', text: 'I need to search. ' },
802+
{ type: 'tool', id: 'call_internal_1', tool: 'web_fetch', state: { status: 'pending', input: { url: 'https://example.com/weather' } } }
803+
]
804+
}
805+
}
806+
},
807+
{
808+
type: 'message.part.updated',
809+
properties: { part: { type: 'text', sessionID: sessionId }, delta: 'The weather is 14C.' }
810+
},
811+
{
812+
type: 'message.updated',
813+
properties: {
814+
info: {
815+
sessionID: sessionId,
816+
finish: 'stop',
817+
parts: [
818+
{ type: 'tool', id: 'call_internal_1', tool: 'web_fetch', state: { status: 'completed', input: { url: 'https://example.com/weather' }, output: '14C' } },
819+
{ type: 'text', text: 'The weather is 14C.' }
820+
]
821+
}
822+
}
823+
}
824+
];
825+
return {
826+
stream: (async function* () {
827+
for (const event of mockEvents) {
828+
yield event;
829+
}
830+
})()
831+
};
832+
});
833+
834+
const res = await request(app)
835+
.post('/v1/chat/completions')
836+
.set('Authorization', 'Bearer test-key')
837+
.send({
838+
model: 'opencode/kimi-k2.5',
839+
messages: [{ role: 'user', content: 'What is the weather in Tokyo?' }],
840+
stream: true
841+
});
842+
843+
expect(res.statusCode).toEqual(200);
844+
expect(res.text).toContain('data: [DONE]');
845+
846+
const chunks = res.text.split('\n').filter(l => l.startsWith('data:') && !l.includes('[DONE]'));
847+
let streamed = '';
848+
for (const line of chunks) {
849+
const json = JSON.parse(line.slice(5).trim());
850+
const delta = json.choices && json.choices[0] && json.choices[0].delta && json.choices[0].delta.content;
851+
if (delta) streamed += delta;
852+
}
853+
// The full answer must be streamed, not just the planning text.
854+
expect(streamed).toEqual('I need to search. The weather is 14C.');
855+
});
856+
782857
test('POST /v1/chat/completions includes reasoning tags in streaming', async () => {
783858
const res = await request(app)
784859
.post('/v1/chat/completions')

0 commit comments

Comments
 (0)