Files
2026-07-13 13:39:12 +08:00

180 lines
5.5 KiB
TypeScript

import { test, before, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createChatPipelineHarness } from "./_chatPipelineHarness.ts";
let h: Awaited<ReturnType<typeof createChatPipelineHarness>>;
before(async () => {
h = await createChatPipelineHarness("stream-recovery");
});
after(async () => {
await h.cleanup();
});
beforeEach(async () => {
await h.resetStorage();
delete process.env.STREAM_RECOVERY_ENABLED;
});
// A 200 SSE stream that ends WITHOUT a terminal `[DONE]` marker = silent truncation.
function truncatedOpenAIStream(): Response {
const chunk = JSON.stringify({
id: "chatcmpl_trunc",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant", content: "PARTIAL" } }],
});
return new Response(`data: ${chunk}\n\n`, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
function completeOpenAIStream(): Response {
const chunk = JSON.stringify({
id: "chatcmpl_full",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { role: "assistant", content: "RECOVERED" } }],
});
return new Response([`data: ${chunk}`, "", "data: [DONE]", ""].join("\n"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
async function readSSE(response: Response): Promise<string> {
const reader = (response.body as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
let out = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) out += decoder.decode(value, { stream: true });
}
return out;
}
test("stream recovery ON: a truncated opening stream is retried transparently", async () => {
await h.seedConnection("openai", { apiKey: "sk-openai-primary" });
const apiKey = await h.seedApiKey();
await h.settingsDb.updateSettings({
resilienceSettings: { streamRecovery: { enabled: true } },
});
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return calls === 1 ? truncatedOpenAIStream() : completeOpenAIStream();
}) as typeof fetch;
const response = await h.handleChat(
h.buildRequest({
authKey: apiKey.key,
body: {
model: "openai/gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "hi" }],
},
})
);
assert.equal(response.status, 200);
const sse = await readSSE(response);
assert.equal(calls, 2, "should have re-opened the upstream exactly once");
assert.match(sse, /RECOVERED/, "client receives the recovered attempt");
assert.match(sse, /\[DONE\]/, "recovered stream carries its terminal marker");
assert.doesNotMatch(sse, /PARTIAL/, "the discarded first attempt must not leak to the client");
});
test("stream recovery OFF (default): a truncated stream is NOT retried", async () => {
await h.seedConnection("openai", { apiKey: "sk-openai-primary" });
const apiKey = await h.seedApiKey();
// No setting, no env → default OFF.
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return truncatedOpenAIStream();
}) as typeof fetch;
const response = await h.handleChat(
h.buildRequest({
authKey: apiKey.key,
body: {
model: "openai/gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "hi" }],
},
})
);
assert.equal(response.status, 200);
await readSSE(response);
assert.equal(calls, 1, "default path calls upstream exactly once — zero behavior change");
});
test("/goal requests enable early stream recovery even when the global default is OFF", async () => {
await h.seedConnection("openai", { apiKey: "sk-openai-primary" });
const apiKey = await h.seedApiKey();
// No setting, no env → global recovery stays OFF. The /goal detector should opt in.
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return calls === 1 ? truncatedOpenAIStream() : completeOpenAIStream();
}) as typeof fetch;
const response = await h.handleChat(
h.buildRequest({
authKey: apiKey.key,
body: {
model: "openai/gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "/goal finish this long-running task" }],
},
})
);
assert.equal(response.status, 200);
const sse = await readSSE(response);
assert.equal(calls, 2, "goal mode should re-open an early-truncated stream");
assert.match(sse, /RECOVERED/, "client receives the recovered attempt");
assert.doesNotMatch(sse, /PARTIAL/, "the discarded first attempt must not leak to the client");
});
test("/goal requests do NOT re-enable stream recovery when the operator explicitly disabled it", async () => {
await h.seedConnection("openai", { apiKey: "sk-openai-primary" });
const apiKey = await h.seedApiKey();
// Operator explicitly turned recovery OFF via a DB/settings override — the
// agentGoalPolicy heuristic must be fail-closed and never override this.
await h.settingsDb.updateSettings({
resilienceSettings: { streamRecovery: { enabled: false } },
});
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return truncatedOpenAIStream();
}) as typeof fetch;
const response = await h.handleChat(
h.buildRequest({
authKey: apiKey.key,
body: {
model: "openai/gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "/goal finish this long-running task" }],
},
})
);
assert.equal(response.status, 200);
await readSSE(response);
assert.equal(
calls,
1,
"explicit operator opt-out must win over the goal-policy heuristic — zero re-open"
);
});