Skip to content

Commit b5470ca

Browse files
Rangshcursoragent
andcommitted
fix(runtime): count only visible thinking toward the empty-step cap
OpenAI Responses emits an empty thinking carrier at reasoning-end whenever provider metadata is present. That is not user-visible progress, so it must not reset the identical textless tool-step counter (#4083). Generated-by: Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 41ad5a7 commit b5470ca

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

‎packages/runtime/src/__tests__/ai-sdk-backend.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6092,6 +6092,65 @@ describe('AiSdkBackend model history', () => {
60926092
);
60936093
});
60946094

6095+
test('stops an unbounded loop when Responses reasoning-end is only an empty carrier', async () => {
6096+
// OpenAI Responses emits `{ kind: 'thinking', text: '' }` at reasoning-end
6097+
// whenever provider metadata is present. That carrier must not count as
6098+
// visible thinking, or identical textless tool steps never reach the cap.
6099+
const reasoningMetadata = {
6100+
openai: {
6101+
itemId: 'rs_empty',
6102+
reasoningEncryptedContent: 'encrypted-carrier',
6103+
},
6104+
};
6105+
let calls = 0;
6106+
const model = new MockLanguageModelV4({
6107+
doStream: async () => {
6108+
calls += 1;
6109+
return {
6110+
stream: simulateReadableStream({
6111+
chunks: [
6112+
{ type: 'stream-start', warnings: [] },
6113+
{ type: 'reasoning-start', id: 'r1', providerMetadata: reasoningMetadata },
6114+
{ type: 'reasoning-end', id: 'r1', providerMetadata: reasoningMetadata },
6115+
{
6116+
type: 'tool-call',
6117+
toolCallId: `tool-${calls}`,
6118+
toolName: 'Read',
6119+
input: JSON.stringify({ path: 'notes.md' }),
6120+
},
6121+
{
6122+
type: 'finish',
6123+
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
6124+
usage: emptyUsage(),
6125+
},
6126+
],
6127+
initialDelayInMs: null,
6128+
chunkDelayInMs: null,
6129+
}),
6130+
};
6131+
},
6132+
});
6133+
const durable = durableTurnHarness('turn-empty-responses-loop', 'keep going');
6134+
const backend = createTestAiSdkBackend({
6135+
sessionId: 'session-1',
6136+
header: header(),
6137+
appendMessage: async () => {},
6138+
connection: connection(),
6139+
apiKey: 'sk-test',
6140+
modelId: 'mock-model-id',
6141+
modelFactory: () => model,
6142+
tools: [testTool('Read', z.object({ path: z.string() }))],
6143+
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
6144+
newId: idGenerator(),
6145+
now: monotonicClock(),
6146+
});
6147+
6148+
const events = await drainDurably(backend.send(durable.input()), durable);
6149+
assert.equal(calls, 3);
6150+
assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit');
6151+
assert.equal(events.filter((event) => event.type === 'tool_start').length, 3);
6152+
});
6153+
60956154
test('aborting during post-stream persistence wins over step-limit completion', async () => {
60966155
const loop = countingToolLoopModel();
60976156
const gate = makeGate();

‎packages/runtime/src/ai-sdk-backend.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2396,7 +2396,12 @@ export class AiSdkBackend implements AgentBackend {
23962396
stepTextPartStartOffset,
23972397
);
23982398
} else if (event.kind === 'thinking') {
2399-
stepSawThinking = true;
2399+
// OpenAI Responses emits an empty thinking carrier at
2400+
// `reasoning-end` whenever provider metadata is present. That
2401+
// is not user-visible progress, so it must not reset the
2402+
// empty-step loop cap (#4083). Persistence still uses
2403+
// `sawStepThinking` so the encrypted carrier round-trips.
2404+
if (event.text.length > 0) stepSawThinking = true;
24002405
sawStepThinking = true;
24012406
stepThinking += event.text;
24022407
if (event.text.length > 0) attemptSawThinking = true;

0 commit comments

Comments
 (0)