Files
OmniRoute/tests/unit/stream-continuation-wiring.test.ts
Dizzle 62ab93d789 fix(sse): reject a low-overlap stream-recovery continuation instead of concatenating it raw (#11152)
Merged after sibling #11151 landed: streamRecovery.ts auto-merged byte-identical to the validated combined board; the test-file conflict (both PRs added suites at the same anchor) resolved keeping all 11 tests — #11151's four clean-stop cases plus this PR's three threshold cases, with the PR's updated partial-tail fixture for the pre-existing overlap test. Full chain green: 32/32 (wiring + continuation + toolcall regression). The documented 8-char overlap threshold ends the silent mid-word gluing. Thank you @maxmad64bis!
2026-08-22 21:56:26 -03:00

295 lines
12 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import {
createRecoverableStream,
scanOpenAiSseText,
} from "../../open-sse/services/streamRecovery.ts";
const enc = new TextEncoder();
/** A clock that jumps +1000ms per call so the holdback commits on the very first chunk. */
function steppingClock() {
let t = 0;
return () => {
t += 1000;
return t;
};
}
/** Build a ReadableStream from string chunks, optionally erroring (truncating) at the end. */
function streamFrom(chunks: string[], opts: { truncateError?: Error } = {}) {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(enc.encode(chunks[i++]));
return;
}
if (opts.truncateError) controller.error(opts.truncateError);
else controller.close();
},
});
}
async function collectText(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const dec = new TextDecoder();
let out = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) out += dec.decode(value, { stream: true });
}
return out;
}
const ROLE = 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n';
const content = (s: string) => `data: {"choices":[{"delta":{"content":${JSON.stringify(s)}}}]}\n\n`;
const reasoning = (s: string) =>
`data: {"choices":[{"delta":{"reasoning_content":${JSON.stringify(s)}}}]}\n\n`;
const finishStopNoContent = 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n';
const finishLengthNoContent = 'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n';
test("mid-stream continuation: stitches the suffix after a silent post-commit truncation", async () => {
// Commits on chunk 1, emits "Hello there world", then ends WITHOUT a terminal marker
// (silent cut).
const initial = streamFrom([ROLE, content("Hello there world")]);
let finalizeCount = 0;
let continueArg = "";
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {
finalizeCount += 1;
},
now: steppingClock(),
continueStream: async (soFar: string) => {
continueArg = soFar;
// The model re-emits only a partial tail of what was already sent ("there world",
// 11 chars — above the 8-char threshold, but NOT the full emitted text, unlike a
// full-string overlap this stays a discriminating test of trimContinuationOverlap's
// partial-tail trim, not just its "accept everything" path) before continuing.
return streamFrom([ROLE, content("there world, nice to meet you!"), "data: [DONE]\n\n"]);
},
});
const out = await collectText(stream);
const scan = scanOpenAiSseText(out);
assert.equal(continueArg, "Hello there world", "continuation is prefilled with the text already sent");
assert.equal(
scan.text,
"Hello there world, nice to meet you!",
"client sees the full answer, partial overlap trimmed, exactly once"
);
assert.equal(scan.terminal, true, "the recovered stream ends with a terminal marker");
assert.equal(finalizeCount, 1, "finalize runs exactly once");
});
test("mid-stream continuation: recovers a post-commit transport error too", async () => {
const initial = streamFrom([ROLE, content("Partial ")], {
truncateError: Object.assign(new Error("terminated"), { code: "UND_ERR_SOCKET" }),
});
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async () => streamFrom([content("Partial answer done."), "data: [DONE]\n\n"]),
});
const scan = scanOpenAiSseText(await collectText(stream));
assert.equal(scan.text, "Partial answer done.");
assert.equal(scan.terminal, true);
});
test("no continuation configured: a silent post-commit truncation closes as before (no recovery)", async () => {
const initial = streamFrom([ROLE, content("Hello wor")]);
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
// continueStream omitted → behavior unchanged from #4131
});
const scan = scanOpenAiSseText(await collectText(stream));
assert.equal(scan.text, "Hello wor", "only the committed text is delivered");
assert.equal(scan.terminal, false, "no synthetic terminal is injected when continuation is off");
});
test("tool-call in flight is never continued (would corrupt tool JSON)", async () => {
const toolDelta =
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"f","arguments":"{\\"a\\":"}}]}}]}\n\n';
const initial = streamFrom([ROLE, toolDelta]); // commits, emits a partial tool call, then truncates
let continued = false;
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async () => {
continued = true;
return streamFrom([content("nope"), "data: [DONE]\n\n"]);
},
});
await collectText(stream);
assert.equal(continued, false, "continuation must NOT fire once a tool call has started streaming");
});
test("mid-stream continuation: a zero-overlap restart is rejected, never concatenated raw", async () => {
// Truncates silently after real, non-empty text — canContinue() fires.
const initial = streamFrom([ROLE, content("Tous les faits sont reunis")]);
let continuations = 0;
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
maxContinuations: 1,
continueStream: async () => {
continuations += 1;
// The model ignores the assistant prefill and restarts on an unrelated sentence —
// zero characters of overlap with what was already emitted.
return streamFrom([
content("Je complete le design - derniere verification"),
"data: [DONE]\n\n",
]);
},
});
const out = await collectText(stream);
const scan = scanOpenAiSseText(out);
assert.equal(
scan.text,
"Tous les faits sont reunis",
"the unrelated restart must never be appended to the already-emitted text"
);
assert.equal(scan.terminal, true, "closes cleanly instead of leaving the client hanging");
assert.equal(continuations, 1, "bounded by maxContinuations — does not loop forever");
});
test("mid-stream continuation: a nonzero overlap below the threshold is rejected too", async () => {
// Genuine 4-character overlap ("pret"), well under the 8-char threshold — this is the
// false-negative case a naive `overlapChars === 0` check would miss (a restart that
// happens to share a short accidental fragment with the emitted tail): must still be
// treated as a suspected restart, not accepted as a genuine resume.
const initial = streamFrom([ROLE, content("Le design est pret")]);
let continuations = 0;
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
maxContinuations: 1,
continueStream: async () => {
continuations += 1;
// Shares only "pret" (4 chars) with the emitted tail, then diverges completely.
return streamFrom([content("pret a partir de zero"), "data: [DONE]\n\n"]);
},
});
const scan = scanOpenAiSseText(await collectText(stream));
assert.equal(
scan.text,
"Le design est pret",
"a below-threshold (but nonzero) overlap must not be accepted as a real resume"
);
assert.equal(continuations, 1);
});
test("mid-stream continuation: a real overlap at or above the threshold is still stitched correctly", async () => {
// Regression guard: the existing happy path (first test in this file, whose updated
// fixture re-emits the 11-char partial tail "there world") still passes below — this test
// adds an overlap AT the threshold boundary to prove Task 3's new check does not fire when
// it shouldn't.
const initial = streamFrom([ROLE, content("The answer to this question")]);
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async () =>
// "question" (8 chars) overlaps the tail of emittedText exactly at the threshold.
streamFrom([content("question is forty-two."), "data: [DONE]\n\n"]),
});
const scan = scanOpenAiSseText(await collectText(stream));
assert.equal(
scan.text,
"The answer to this question is forty-two.",
"an overlap meeting the threshold is trimmed and stitched, not rejected"
);
});
test("mid-stream continuation: a clean stop with reasoning-only output (no answer) triggers a continuation", async () => {
const initial = streamFrom([
ROLE,
reasoning("the model thinks through the problem here..."),
finishStopNoContent,
]);
let continueArg = "__unset__";
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async (soFar: string) => {
continueArg = soFar;
return streamFrom([content("Here is the actual answer."), "data: [DONE]\n\n"]);
},
});
const out = await collectText(stream);
const scan = scanOpenAiSseText(out);
assert.equal(continueArg, "", "nothing usable was emitted — the re-request has an empty prefill");
assert.equal(
scan.text,
"Here is the actual answer.",
"the client gets a real answer instead of silence"
);
assert.equal(scan.terminal, true);
});
test("mid-stream continuation: a clean stop with truly empty output (no text, no reasoning) is left unchanged", async () => {
const initial = streamFrom([ROLE, finishStopNoContent]);
let continued = false;
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async () => {
continued = true;
return streamFrom([content("nope"), "data: [DONE]\n\n"]);
},
});
await collectText(stream);
assert.equal(
continued,
false,
"no reasoning trace means there is nothing to act on — do not guess"
);
});
test("mid-stream continuation: finish_reason 'length' with reasoning-only output does NOT trigger a continuation", async () => {
// Regression guard for a blocker found in cross-review: widening the gate to any
// terminal marker (instead of the literal finish_reason "stop") would wrongly spend a
// continuation attempt on a token-limit cutoff, which is out of this fix's scope.
const initial = streamFrom([
ROLE,
reasoning("the model was still thinking when it hit the token limit..."),
finishLengthNoContent,
]);
let continued = false;
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async () => {
continued = true;
return streamFrom([content("nope"), "data: [DONE]\n\n"]);
},
});
await collectText(stream);
assert.equal(continued, false, "finish_reason 'length' is out of scope for this fix");
});
test("mid-stream continuation: real content alongside reasoning at a clean stop is left unchanged (non-regression)", async () => {
const initial = streamFrom([
ROLE,
reasoning("thinking..."),
content("The real answer."),
finishStopNoContent,
]);
let continued = false;
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
continueStream: async () => {
continued = true;
return streamFrom([content("nope"), "data: [DONE]\n\n"]);
},
});
const scan = scanOpenAiSseText(await collectText(stream));
assert.equal(continued, false, "real content was delivered — nothing to recover");
assert.equal(scan.text, "The real answer.");
});