Files
OmniRoute/tests/unit/responses-continuation-passthrough-client-payload.test.ts
Markus Hartung 04dba0460e fix(responses-continuation): recover a real id/output for passthrough and translate-mode replies (#11434)
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!
2026-08-24 19:57:12 -03:00

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");
});