Skip to content

fix(core): avoid ReadableStream lock after fullStream startup probe#1103

Merged
omeraplak merged 2 commits intomainfrom
fix/core-fullstream-probe-lock
Feb 23, 2026
Merged

fix(core): avoid ReadableStream lock after fullStream startup probe#1103
omeraplak merged 2 commits intomainfrom
fix/core-fullstream-probe-lock

Conversation

@omeraplak
Copy link
Copy Markdown
Member

@omeraplak omeraplak commented Feb 23, 2026

PR Checklist

Please check if your PR fulfills the following requirements:

Bugs / Features

What is the current behavior?

Agent.streamText() / Agent.streamObject() can replace a provider result's getter-based fullStream with a fixed stream after startup probing.

For AI SDK results that rely on tee-based fullStream accessors, this can break multi-consumer behavior and cause:

TypeError [ERR_INVALID_STATE]: Invalid state: ReadableStream is locked

when SDK consumers iterate result.fullStream while other accessors (e.g. result.text / UI stream helpers) are also active.

What is the new behavior?

  • Preserve getter-based teeing fullStream results after probing.
  • Only replace fullStream when the result is not getter/tee-based.
  • Apply the same handling to both streamText and streamObject.
  • Add regression coverage that simulates getter-based tee stream results and validates concurrent result.fullStream + result.text usage.

fixes (issue)

Notes for reviewers

Changes:

  • packages/core/src/agent/agent.ts
  • packages/core/src/agent/agent.spec.ts
  • .changeset/witty-cameras-smile.md

Validation:

  • pnpm -C packages/core test -- src/agent/agent.spec.ts
  • pnpm -C packages/core test -- src/agent/subagent/index.spec.ts
  • SDK smoke (from examples/base with env loaded): Agent.streamText(...) consumed via result.fullStream and result.text concurrently, no lock error.

Summary by cubic

Prevents ReadableStream lock errors after startup probing by preserving getter-based tee behavior for fullStream in streamText and streamObject. Enables concurrent use of result.fullStream and result.text without errors.

  • Bug Fixes
    • Preserve teeing getter for fullStream after probe; avoid replacing it.
    • Only clone fullStream when the result is not getter-based.
    • Apply the fix to both streamText and streamObject.
    • Add and tighten tests validating concurrent fullStream + text usage with tee-based results.

Written for commit 39abf1d. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented a TypeError when iterating the full stream while other accessors consume the same stream, preserving correct multi-consumer streaming behavior.
  • Tests

    • Added a test ensuring getter-based stream teeing remains available after startup probing.
  • Chores

    • Added changelog entry documenting this patch release.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 23, 2026

🦋 Changeset detected

Latest commit: 39abf1d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@voltagent/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@joggrbot

This comment has been minimized.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 23, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Refactors stream probing in agent.ts to preserve getter-based fullStream teeing behavior, adds helpers to detect teeing-capable streams, and introduces a test and changelog entry verifying that probing does not lock multi-consumer fullStream access. (43 words)

Changes

Cohort / File(s) Summary
Changelog
\.changeset/witty-cameras-smile.md
Adds a changelog entry documenting preservation of getter-based fullStream tee behavior after startup probing in streamText/streamObject.
Test Suite
packages/core/src/agent/agent.spec.ts
Adds a test "does not lock getter-based teeing fullStream after probe" that mocks a teeing fullStream, iterates the stream, and asserts correct event emission and assembled text.
Stream probing logic
packages/core/src/agent/agent.ts
Refactors probing to capture the original fullStream, introduces withProbedFullStream<TResult> to decide whether to swap the probed stream, and adds usesGetterBasedTeeingFullStream and findPropertyDescriptor helpers to detect getter-based teeing and inspect descriptors.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nudged the stream, then watched it part,
A getter kept both flows close to heart.
Probes tiptoed in but left no lock,
Text stitched whole — no ticking clock.
Hooray! The tee sings on, hop hop! 🎶

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: preserving getter-based fullStream behavior after startup probing to avoid ReadableStream lock errors.
Description check ✅ Passed The PR description comprehensively covers all required template sections with concrete details about the bug, new behavior, test coverage, and validation steps performed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/core-fullstream-probe-lock

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
packages/core/src/agent/agent.spec.ts (2)

1140-1141: Consider asserting "start" is still present to directly cover the probe-swallowed-event scenario

The "start" event is the very chunk the agent's probe reads when detecting stream type. For a non-getter-based stream the probe would consume it, losing it for downstream consumers. Asserting it appears in the re-teed result strengthens the regression signal beyond just checking that content/finish survive.

🧪 Suggested assertion addition
      expect(emittedTypes).toContain("text-delta");
+     expect(emittedTypes).toContain("start");
      expect(emittedTypes).toContain("finish");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent.spec.ts` around lines 1140 - 1141, Add an
assertion to ensure the "start" event is preserved in the re-teed stream: update
the test that collects emittedTypes (the assertion block containing
expect(emittedTypes).toContain("text-delta") and
expect(emittedTypes).toContain("finish")) to also assert
expect(emittedTypes).toContain("start") so the probe-swallowed-event regression
is directly covered; locate the emittedTypes variable in the failing test in
agent.spec.ts and add the new expectation alongside the existing ones.

1085-1088: teeStream() should be private

The method is only ever invoked through the get fullStream() getter. Leaving it accessible (no modifier = public) exposes internal state-mutation logic to the outer scope unnecessarily.

♻️ Proposed fix
-      teeStream(): ai.AsyncIterableStream<any> {
+      private teeStream(): ai.AsyncIterableStream<any> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent.spec.ts` around lines 1085 - 1088, The
teeStream method is currently public but only used by the fullStream getter;
mark it private to prevent exposing internal state mutation. Update the method
declaration for teeStream() to include the private modifier (private
teeStream(): ai.AsyncIterableStream<any>) and verify only the fullStream getter
calls it; no other API surface should reference teeStream after this change.
packages/core/src/agent/agent.ts (1)

5324-5369: Verify unused probed tee branch is cleaned up when preserving getter-based fullStream.

If probeStreamStart tees a ReadableStream, returning the original result here leaves the probedFullStream branch unconsumed. With standard ReadableStream.tee() backpressure, that can stall or leak on long streams. Please confirm AI SDK’s teeing implementation makes this safe; otherwise, consider cancelling/consuming the probed branch when you keep the original result.

Possible safeguard (only if tee branches need cleanup)
     if (this.usesGetterBasedTeeingFullStream(result)) {
+      this.discardStream(probedFullStream);
       // AI SDK stream results expose fullStream via a teeing getter.
       // Preserving the original instance keeps that multi-consumer behavior intact.
       return result;
     }
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/agent/agent.spec.ts`:
- Around line 1140-1141: Add an assertion to ensure the "start" event is
preserved in the re-teed stream: update the test that collects emittedTypes (the
assertion block containing expect(emittedTypes).toContain("text-delta") and
expect(emittedTypes).toContain("finish")) to also assert
expect(emittedTypes).toContain("start") so the probe-swallowed-event regression
is directly covered; locate the emittedTypes variable in the failing test in
agent.spec.ts and add the new expectation alongside the existing ones.
- Around line 1085-1088: The teeStream method is currently public but only used
by the fullStream getter; mark it private to prevent exposing internal state
mutation. Update the method declaration for teeStream() to include the private
modifier (private teeStream(): ai.AsyncIterableStream<any>) and verify only the
fullStream getter calls it; no other API surface should reference teeStream
after this change.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Feb 23, 2026

Deploying voltagent with  Cloudflare Pages  Cloudflare Pages

Latest commit: 39abf1d
Status: ✅  Deploy successful!
Preview URL: https://b7e900bb.voltagent.pages.dev
Branch Preview URL: https://fix-core-fullstream-probe-lo.voltagent.pages.dev

View logs

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/core/src/agent/agent.ts">

<violation number="1" location="packages/core/src/agent/agent.ts:5334">
P0: The unconsumed `probedFullStream` tee branch must be discarded to prevent unbounded memory buffering (memory leak).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

probedFullStream: TResult["fullStream"],
): TResult {
if (probedFullStream === originalFullStream) {
return result;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: The unconsumed probedFullStream tee branch must be discarded to prevent unbounded memory buffering (memory leak).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/agent/agent.ts, line 5334:

<comment>The unconsumed `probedFullStream` tee branch must be discarded to prevent unbounded memory buffering (memory leak).</comment>

<file context>
@@ -5317,6 +5321,53 @@ export class Agent {
+    probedFullStream: TResult["fullStream"],
+  ): TResult {
+    if (probedFullStream === originalFullStream) {
+      return result;
+    }
+
</file context>
Suggested change
return result;
this.discardStream(probedFullStream);
return result;
Fix with Cubic

@omeraplak omeraplak merged commit edd7181 into main Feb 23, 2026
23 checks passed
@omeraplak omeraplak deleted the fix/core-fullstream-probe-lock branch February 23, 2026 03:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant