Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/witty-cameras-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@voltagent/core": patch
---

fix: preserve getter-based `fullStream` tee behavior after startup probing in `streamText`/`streamObject`

This prevents `TypeError [ERR_INVALID_STATE]: Invalid state: ReadableStream is locked` when SDK consumers iterate `result.fullStream` while other result accessors (such as `result.text` or UI stream helpers) are also consuming the stream.
86 changes: 86 additions & 0 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,92 @@ Use pandas and summarize findings.`.split("\n"),
expect(emittedTypes).toContain("text-delta");
expect(emittedTypes).toContain("finish");
});

it("does not lock getter-based teeing fullStream after probe", async () => {
const agent = new Agent({
name: "TestAgent",
instructions: "You are a helpful assistant",
model: mockModel as any,
});

const streamParts = [
{ type: "start" as const },
{ type: "text-start" as const, id: "text-1" },
{ type: "text-delta" as const, id: "text-1", delta: "Hello " },
{ type: "text-delta" as const, id: "text-1", delta: "world" },
{ type: "text-end" as const, id: "text-1" },
{ type: "finish" as const, finishReason: "stop", totalUsage: {} },
];

class TeeingStreamResult {
private baseStream: ReadableStream<any>;
private readonly cachedText: Promise<string>;

constructor(parts: ReadonlyArray<any>) {
this.baseStream = convertArrayToReadableStream([...parts]);
this.cachedText = this.consumeText();
}

private teeStream(): ai.AsyncIterableStream<any> {
const [probeStream, passthroughStream] = this.baseStream.tee();
this.baseStream = passthroughStream;
return toAsyncIterableStream(probeStream);
}

get fullStream(): ai.AsyncIterableStream<any> {
return this.teeStream();
}

get text(): Promise<string> {
return this.cachedText;
}

get textStream(): AsyncIterable<string> {
return (async function* () {
yield "Hello world";
})();
}

readonly usage = Promise.resolve({
inputTokens: 10,
outputTokens: 2,
totalTokens: 12,
});
readonly finishReason = Promise.resolve("stop");
readonly warnings: never[] = [];
readonly toUIMessageStream = vi.fn();
readonly toUIMessageStreamResponse = vi.fn();
readonly pipeUIMessageStreamToResponse = vi.fn();
readonly pipeTextStreamToResponse = vi.fn();
readonly toTextStreamResponse = vi.fn();
readonly partialOutputStream = undefined;

private async consumeText(): Promise<string> {
let text = "";
for await (const part of this.fullStream) {
if (part.type === "text-delta" && typeof part.delta === "string") {
text += part.delta;
}
}
return text;
}
}

const mockStream = new TeeingStreamResult(streamParts);
vi.mocked(ai.streamText).mockReturnValue(mockStream as any);

const result = await agent.streamText("answer me");
const emittedTypes: string[] = [];

for await (const part of result.fullStream as AsyncIterable<{ type: string }>) {
emittedTypes.push(part.type);
}

expect(emittedTypes).toContain("start");
expect(emittedTypes).toContain("text-delta");
expect(emittedTypes).toContain("finish");
await expect(result.text).resolves.toBe("Hello world");
});
});

describe("Tool Management", () => {
Expand Down
71 changes: 61 additions & 10 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2010,11 +2010,13 @@ export class Agent {
},
});

const probeResult = await this.probeStreamStart(streamResult.fullStream, attemptState);
const streamResultForConsumption =
probeResult.stream === streamResult.fullStream
? streamResult
: this.cloneResultWithFullStream(streamResult, probeResult.stream);
const originalFullStream = streamResult.fullStream;
const probeResult = await this.probeStreamStart(originalFullStream, attemptState);
const streamResultForConsumption = this.withProbedFullStream(
streamResult,
originalFullStream,
probeResult.stream,
);

if (probeResult.status === "error") {
this.discardStream(streamResultForConsumption.fullStream);
Expand Down Expand Up @@ -3136,11 +3138,13 @@ export class Agent {
},
});

const probeResult = await this.probeStreamStart(streamResult.fullStream, attemptState);
const streamResultForConsumption =
probeResult.stream === streamResult.fullStream
? streamResult
: this.cloneResultWithFullStream(streamResult, probeResult.stream);
const originalFullStream = streamResult.fullStream;
const probeResult = await this.probeStreamStart(originalFullStream, attemptState);
const streamResultForConsumption = this.withProbedFullStream(
streamResult,
originalFullStream,
probeResult.stream,
);

if (probeResult.status === "error") {
this.discardStream(streamResultForConsumption.fullStream);
Expand Down Expand Up @@ -5317,6 +5321,53 @@ export class Agent {
return clone;
}

private withProbedFullStream<
TResult extends {
fullStream: AsyncIterableStream<unknown>;
},
>(
result: TResult,
originalFullStream: TResult["fullStream"],
probedFullStream: TResult["fullStream"],
): TResult {
if (probedFullStream === originalFullStream) {
return result;

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

Copy link
Copy Markdown
Contributor

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

}

if (this.usesGetterBasedTeeingFullStream(result)) {
// AI SDK stream results expose fullStream via a teeing getter.
// Preserving the original instance keeps that multi-consumer behavior intact.
return result;
}

return this.cloneResultWithFullStream(result, probedFullStream);
}

private usesGetterBasedTeeingFullStream(result: {
fullStream: AsyncIterableStream<unknown>;
}): boolean {
const descriptor = this.findPropertyDescriptor(result, "fullStream");
return (
typeof descriptor?.get === "function" &&
typeof (result as { teeStream?: unknown }).teeStream === "function"
);
}

private findPropertyDescriptor(
target: object,
propertyName: string,
): PropertyDescriptor | undefined {
let current: object | null = target;
while (current) {
const descriptor = Object.getOwnPropertyDescriptor(current, propertyName);
if (descriptor) {
return descriptor;
}
current = Object.getPrototypeOf(current);
}
return undefined;
}

private toAsyncIterableStream<T>(stream: ReadableStream<T>): AsyncIterableStream<T> {
const asyncStream = stream as AsyncIterableStream<T>;

Expand Down