Skip to content

extractReasoningMiddleware loses text when text blocks overlap #21050

Description

@brennanbutler01

Description

extractReasoningMiddleware loses a text block's start event when a provider opens two text blocks before emitting their deltas. The final text then omits the earlier block, and fullStream contains text part a not found errors.

The same stream works without the middleware. There are no reasoning tags in this reproduction, so adding the middleware should preserve both blocks.

The cause appears to be delayedTextStart in packages/ai/src/middleware/extract-reasoning-middleware.ts: it stores one event for the whole stream, while the rest of the extraction state is keyed by text ID. text-start(b) overwrites the saved text-start(a). When a's delta arrives, the middleware emits b's start followed by a's delta.

Reproduction

No API key or network calls needed. Install ai@7.0.101 and zod@4.4.3, save as repro.mts, and run node repro.mts with Node 24:

import { extractReasoningMiddleware, streamText, wrapLanguageModel } from 'ai';
import { MockLanguageModelV4 } from 'ai/test';

for (const wrapped of [false, true]) {
  const model = new MockLanguageModelV4({ doStream: { stream: new ReadableStream({ start(controller) {
    controller.enqueue({ type: 'stream-start', warnings: [] });
    controller.enqueue({ type: 'text-start', id: 'a' });
    controller.enqueue({ type: 'text-start', id: 'b' });
    controller.enqueue({ type: 'text-delta', id: 'a', delta: 'Alpha.' });
    controller.enqueue({ type: 'text-delta', id: 'b', delta: 'Beta.' });
    controller.enqueue({ type: 'text-end', id: 'a' });
    controller.enqueue({ type: 'text-end', id: 'b' });
    controller.enqueue({ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: {
      inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
      outputTokens: { total: 1, text: 1, reasoning: 0 },
    } });
    controller.close();
  } }) } });
  const errors: unknown[] = [];
  const result = streamText({
    model: wrapped ? wrapLanguageModel({ model, middleware: extractReasoningMiddleware({ tagName: 'think' }) }) : model,
    prompt: 'Synthetic prompt.',
    maxRetries: 0,
  });
  for await (const event of result.fullStream) {
    if (event.type === 'error') errors.push(event.error);
  }
  console.log({ wrapped, text: await result.text, errors });
}

Actual output:

{ wrapped: false, text: 'Alpha.Beta.', errors: [] }
{
  wrapped: true,
  text: 'Beta.',
  errors: [ 'text part a not found', 'text part a not found' ]
}

Expected: both runs return Alpha.Beta. with no error chunks.

I reproduced this on Node 22.20.0 and 24.13.0. Sequential text blocks pass with or without the middleware, and overlapping blocks pass without it. The reproduction passes strict TypeScript checking. The middleware source is unchanged on main at 12845693d7a6a517dc633d6b6a2e4f5bfd24d2ec (source comparison; runtime tests used the published package).

I checked #7774 / #8036, which introduced the delayed start, and #7305. This case concerns distinct text IDs losing their start event, rather than when reasoning is displayed. PR #15583 concerns unfinished tag buffering.

I used AI assistance to investigate and prepare the reproduction and tests.

AI SDK Version

  • ai: 7.0.101
  • zod: 4.4.3
  • Node.js: 22.20.0 and 24.13.0

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions