mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Validado em lote combinado (batch-0824h2, junto de #11435/#11436/#11437) contra o tip de release/v3.8.51: typecheck:core limpo, gates estáticos OK, 127/127 testes focados passando. Investigação sólida com repro real via container isolado, três causas independentes identificadas e corrigidas com testes de regressão dedicados para cada uma. Obrigado pela contribuição!
148 lines
6.0 KiB
TypeScript
148 lines
6.0 KiB
TypeScript
/**
|
|
* Regression test for the "previous_response_id continuation never engages
|
|
* through a passthrough Responses-API connection" bug.
|
|
*
|
|
* Root cause (three independent gaps, all in the client-facing path):
|
|
*
|
|
* 1. Passthrough mode's per-event loop only pushed each raw SSE event into
|
|
* providerPayloadCollector, never clientPayloadCollector -- so for a
|
|
* plain-text Responses-API reply (no tool calls, no textual-tool-call
|
|
* conversion), clientPayloadCollector.getEvents() was always empty.
|
|
* 2. onComplete's `clientPayload` was unconditionally built from a
|
|
* synthesized chat-completions-shaped `responseBody` ({choices: [...]}),
|
|
* even for a Responses-API client -- so it never carried a real `id` or
|
|
* Responses-shaped `output`, unlike the sibling `providerPayload` builder
|
|
* right next to it (which already had the OPENAI_RESPONSES carve-out).
|
|
* 3. clientPayloadCollector.build()'s returned object always nests the
|
|
* caller-supplied summary under `.summary` (see createStructuredSSECollector
|
|
* in streamPayloadCollector.ts) -- extractResponsesId in
|
|
* chatCore/attemptLogging.ts and resolvePreviousResponseState in
|
|
* src/lib/db/responsesContinuationStore.ts both read `.id`/`.output`
|
|
* directly, so even a correctly-populated events list produced a
|
|
* clientResponse whose id/output were invisible to them.
|
|
*
|
|
* Net effect: `call_logs.response_id` was NEVER populated for a passthrough
|
|
* Responses-API reply, so every `previous_response_id` continuation attempt
|
|
* against such a connection failed with a bare HTTP 400
|
|
* ("previous_response_not_found") -- silently, since openclaw-style clients
|
|
* recover by resending full history, so nothing user-visible looked broken.
|
|
*
|
|
* This test exercises only gap #1 and #2 (the stream.ts side) via the real
|
|
* createSSEStream() transform, the same harness used by
|
|
* responses-commentary-passthrough-6199.test.ts. Gap #3's two read-side fixes
|
|
* are covered directly in responses-continuation-store.test.ts (the
|
|
* `.summary.output` fallback) and would need their own extractResponsesId
|
|
* unit coverage if that function is exported for testing.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
|
|
|
|
const textEncoder = new TextEncoder();
|
|
|
|
type OnCompletePayload = {
|
|
status: number;
|
|
clientPayload?: unknown;
|
|
providerPayload?: unknown;
|
|
};
|
|
|
|
async function runPassthrough(
|
|
chunks: string[]
|
|
): Promise<{ output: string; onCompletePayload: OnCompletePayload | undefined }> {
|
|
let onCompletePayload: OnCompletePayload | undefined;
|
|
const source = new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of chunks) {
|
|
controller.enqueue(textEncoder.encode(chunk));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
const output = await new Response(
|
|
source.pipeThrough(
|
|
createSSEStream({
|
|
mode: "passthrough",
|
|
provider: "openai-compatible",
|
|
clientResponseFormat: "openai-responses",
|
|
sourceFormat: "openai-responses",
|
|
model: "mock-model",
|
|
onComplete: (payload: OnCompletePayload) => {
|
|
onCompletePayload = payload;
|
|
},
|
|
})
|
|
)
|
|
).text();
|
|
return { output, onCompletePayload };
|
|
}
|
|
|
|
function sse(event: object): string {
|
|
return `data: ${JSON.stringify(event)}\n\n`;
|
|
}
|
|
|
|
test("passthrough onComplete's clientPayload carries a real Responses id + output for a plain-text reply", async () => {
|
|
// The minimal shape a real upstream (or a scripted test double) sends for a
|
|
// plain-text reply: a single terminal response.completed frame, no
|
|
// response.created/output_item.added lifecycle events first -- this is
|
|
// exactly what tripped the bug, since it never touched the textual-tool-call
|
|
// conversion path that happened to already push into clientPayloadCollector.
|
|
const { onCompletePayload } = await runPassthrough([
|
|
sse({
|
|
type: "response.completed",
|
|
response: {
|
|
id: "resp_plain_text_1",
|
|
status: "completed",
|
|
output: [
|
|
{
|
|
id: "msg_resp_plain_text_1",
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: "hello there", annotations: [] }],
|
|
},
|
|
],
|
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
assert.ok(onCompletePayload, "onComplete must fire");
|
|
const clientPayload = onCompletePayload!.clientPayload as
|
|
| { id?: unknown; summary?: { id?: unknown; output?: unknown } }
|
|
| undefined;
|
|
assert.ok(clientPayload, "clientPayload must be present");
|
|
|
|
// clientPayloadCollector.build() nests the summary; accept either shape so
|
|
// this test survives a future change to the wrapping, but the id/output
|
|
// MUST be findable one way or the other -- that's the actual contract
|
|
// extractResponsesId / resolvePreviousResponseState depend on.
|
|
const id = clientPayload!.id ?? clientPayload!.summary?.id;
|
|
const output = clientPayload!.summary?.output;
|
|
assert.equal(id, "resp_plain_text_1", "the real Responses id must survive into clientPayload");
|
|
assert.ok(Array.isArray(output) && output.length === 1, "the real output array must survive too");
|
|
});
|
|
|
|
test("passthrough forwards the plain-text reply to the client unchanged (no regression)", async () => {
|
|
const { output } = await runPassthrough([
|
|
sse({
|
|
type: "response.completed",
|
|
response: {
|
|
id: "resp_plain_text_2",
|
|
status: "completed",
|
|
output: [
|
|
{
|
|
id: "msg_resp_plain_text_2",
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: "hello again", annotations: [] }],
|
|
},
|
|
],
|
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
assert.ok(output.includes("hello again"), "the client-visible SSE stream must still carry the reply");
|
|
assert.ok(output.includes("resp_plain_text_2"), "the client-visible response id must be unchanged");
|
|
});
|