Skip to content

Commit edd7181

Browse files
authored
fix(core): avoid ReadableStream lock after fullStream startup probe (#1103)
* fix(core): avoid fullStream lock after startup probe * test(core): tighten teeing fullStream regression assertions
1 parent 82074b9 commit edd7181

3 files changed

Lines changed: 154 additions & 10 deletions

File tree

.changeset/witty-cameras-smile.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@voltagent/core": patch
3+
---
4+
5+
fix: preserve getter-based `fullStream` tee behavior after startup probing in `streamText`/`streamObject`
6+
7+
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.

packages/core/src/agent/agent.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,92 @@ Use pandas and summarize findings.`.split("\n"),
10561056
expect(emittedTypes).toContain("text-delta");
10571057
expect(emittedTypes).toContain("finish");
10581058
});
1059+
1060+
it("does not lock getter-based teeing fullStream after probe", async () => {
1061+
const agent = new Agent({
1062+
name: "TestAgent",
1063+
instructions: "You are a helpful assistant",
1064+
model: mockModel as any,
1065+
});
1066+
1067+
const streamParts = [
1068+
{ type: "start" as const },
1069+
{ type: "text-start" as const, id: "text-1" },
1070+
{ type: "text-delta" as const, id: "text-1", delta: "Hello " },
1071+
{ type: "text-delta" as const, id: "text-1", delta: "world" },
1072+
{ type: "text-end" as const, id: "text-1" },
1073+
{ type: "finish" as const, finishReason: "stop", totalUsage: {} },
1074+
];
1075+
1076+
class TeeingStreamResult {
1077+
private baseStream: ReadableStream<any>;
1078+
private readonly cachedText: Promise<string>;
1079+
1080+
constructor(parts: ReadonlyArray<any>) {
1081+
this.baseStream = convertArrayToReadableStream([...parts]);
1082+
this.cachedText = this.consumeText();
1083+
}
1084+
1085+
private teeStream(): ai.AsyncIterableStream<any> {
1086+
const [probeStream, passthroughStream] = this.baseStream.tee();
1087+
this.baseStream = passthroughStream;
1088+
return toAsyncIterableStream(probeStream);
1089+
}
1090+
1091+
get fullStream(): ai.AsyncIterableStream<any> {
1092+
return this.teeStream();
1093+
}
1094+
1095+
get text(): Promise<string> {
1096+
return this.cachedText;
1097+
}
1098+
1099+
get textStream(): AsyncIterable<string> {
1100+
return (async function* () {
1101+
yield "Hello world";
1102+
})();
1103+
}
1104+
1105+
readonly usage = Promise.resolve({
1106+
inputTokens: 10,
1107+
outputTokens: 2,
1108+
totalTokens: 12,
1109+
});
1110+
readonly finishReason = Promise.resolve("stop");
1111+
readonly warnings: never[] = [];
1112+
readonly toUIMessageStream = vi.fn();
1113+
readonly toUIMessageStreamResponse = vi.fn();
1114+
readonly pipeUIMessageStreamToResponse = vi.fn();
1115+
readonly pipeTextStreamToResponse = vi.fn();
1116+
readonly toTextStreamResponse = vi.fn();
1117+
readonly partialOutputStream = undefined;
1118+
1119+
private async consumeText(): Promise<string> {
1120+
let text = "";
1121+
for await (const part of this.fullStream) {
1122+
if (part.type === "text-delta" && typeof part.delta === "string") {
1123+
text += part.delta;
1124+
}
1125+
}
1126+
return text;
1127+
}
1128+
}
1129+
1130+
const mockStream = new TeeingStreamResult(streamParts);
1131+
vi.mocked(ai.streamText).mockReturnValue(mockStream as any);
1132+
1133+
const result = await agent.streamText("answer me");
1134+
const emittedTypes: string[] = [];
1135+
1136+
for await (const part of result.fullStream as AsyncIterable<{ type: string }>) {
1137+
emittedTypes.push(part.type);
1138+
}
1139+
1140+
expect(emittedTypes).toContain("start");
1141+
expect(emittedTypes).toContain("text-delta");
1142+
expect(emittedTypes).toContain("finish");
1143+
await expect(result.text).resolves.toBe("Hello world");
1144+
});
10591145
});
10601146

10611147
describe("Tool Management", () => {

packages/core/src/agent/agent.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,11 +2010,13 @@ export class Agent {
20102010
},
20112011
});
20122012

2013-
const probeResult = await this.probeStreamStart(streamResult.fullStream, attemptState);
2014-
const streamResultForConsumption =
2015-
probeResult.stream === streamResult.fullStream
2016-
? streamResult
2017-
: this.cloneResultWithFullStream(streamResult, probeResult.stream);
2013+
const originalFullStream = streamResult.fullStream;
2014+
const probeResult = await this.probeStreamStart(originalFullStream, attemptState);
2015+
const streamResultForConsumption = this.withProbedFullStream(
2016+
streamResult,
2017+
originalFullStream,
2018+
probeResult.stream,
2019+
);
20182020

20192021
if (probeResult.status === "error") {
20202022
this.discardStream(streamResultForConsumption.fullStream);
@@ -3136,11 +3138,13 @@ export class Agent {
31363138
},
31373139
});
31383140

3139-
const probeResult = await this.probeStreamStart(streamResult.fullStream, attemptState);
3140-
const streamResultForConsumption =
3141-
probeResult.stream === streamResult.fullStream
3142-
? streamResult
3143-
: this.cloneResultWithFullStream(streamResult, probeResult.stream);
3141+
const originalFullStream = streamResult.fullStream;
3142+
const probeResult = await this.probeStreamStart(originalFullStream, attemptState);
3143+
const streamResultForConsumption = this.withProbedFullStream(
3144+
streamResult,
3145+
originalFullStream,
3146+
probeResult.stream,
3147+
);
31443148

31453149
if (probeResult.status === "error") {
31463150
this.discardStream(streamResultForConsumption.fullStream);
@@ -5317,6 +5321,53 @@ export class Agent {
53175321
return clone;
53185322
}
53195323

5324+
private withProbedFullStream<
5325+
TResult extends {
5326+
fullStream: AsyncIterableStream<unknown>;
5327+
},
5328+
>(
5329+
result: TResult,
5330+
originalFullStream: TResult["fullStream"],
5331+
probedFullStream: TResult["fullStream"],
5332+
): TResult {
5333+
if (probedFullStream === originalFullStream) {
5334+
return result;
5335+
}
5336+
5337+
if (this.usesGetterBasedTeeingFullStream(result)) {
5338+
// AI SDK stream results expose fullStream via a teeing getter.
5339+
// Preserving the original instance keeps that multi-consumer behavior intact.
5340+
return result;
5341+
}
5342+
5343+
return this.cloneResultWithFullStream(result, probedFullStream);
5344+
}
5345+
5346+
private usesGetterBasedTeeingFullStream(result: {
5347+
fullStream: AsyncIterableStream<unknown>;
5348+
}): boolean {
5349+
const descriptor = this.findPropertyDescriptor(result, "fullStream");
5350+
return (
5351+
typeof descriptor?.get === "function" &&
5352+
typeof (result as { teeStream?: unknown }).teeStream === "function"
5353+
);
5354+
}
5355+
5356+
private findPropertyDescriptor(
5357+
target: object,
5358+
propertyName: string,
5359+
): PropertyDescriptor | undefined {
5360+
let current: object | null = target;
5361+
while (current) {
5362+
const descriptor = Object.getOwnPropertyDescriptor(current, propertyName);
5363+
if (descriptor) {
5364+
return descriptor;
5365+
}
5366+
current = Object.getPrototypeOf(current);
5367+
}
5368+
return undefined;
5369+
}
5370+
53205371
private toAsyncIterableStream<T>(stream: ReadableStream<T>): AsyncIterableStream<T> {
53215372
const asyncStream = stream as AsyncIterableStream<T>;
53225373

0 commit comments

Comments
 (0)