Skip to content

Commit 0a7524b

Browse files
committed
test(compact): cover combo failover and streaming
1 parent b9e9899 commit 0a7524b

2 files changed

Lines changed: 138 additions & 6 deletions

File tree

src/server/responses/compact.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -553,9 +553,9 @@ export async function handleResponsesCompact(
553553
}
554554
}
555555

556-
// Native /responses/compact exists on the canonical ChatGPT backend and on the
557-
// official OpenAI API. Any other Responses-shaped gateway must take the routed
558-
// summarizer path below, or compaction fails against an endpoint it never had (#422).
556+
// Native /responses/compact exists on the canonical ChatGPT backend and on the
557+
// official OpenAI API. Any other Responses-shaped gateway must take the routed
558+
// summarizer path below, or compaction fails against an endpoint it never had (#422).
559559
// Combo-resolved targets skip native compact so failover can advance through the
560560
// combo target list when the picked model returns 429/5xx — the routed path below
561561
// dispatches through handleResponses → handleComboResponses with full failover.
@@ -997,8 +997,10 @@ export async function handleResponsesCompact(
997997
...raw,
998998
// Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the
999999
// native compact endpoint either, so run its synthetic compaction as SSE and collapse
1000-
// the completed event back into the v1 compact JSON contract below.
1001-
stream: accountGatedCompactWireModel ? true : false,
1000+
// the completed event back into the v1 compact JSON contract below. Combo-dispatched
1001+
// turns also go out as SSE: failover can land on a canonical child that rejects a
1002+
// non-streaming turn, and every combo-capable provider already serves streaming traffic.
1003+
stream: accountGatedCompactWireModel || route.combo ? true : false,
10021004
input: [...inputItems, { type: "compaction_trigger" }],
10031005
};
10041006
const internalHeaders = new Headers({ "content-type": "application/json" });

tests/server-combo-failover-e2e.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
responseStatePersistPendingForTests,
3737
} from "../src/responses/state";
3838
import { clearCursorThreadContinuityForTests } from "../src/adapters/cursor/thread-continuity";
39+
import { COMPACT_PROMPT, encodeCompactionSummary } from "../src/responses/compaction";
3940

4041
// Full-suite Windows load: startServer + combo rename/delete management flows exceed the
4142
// default 5s per-test budget (same flake class as 810fa115 / claude-management-api).
@@ -112,6 +113,7 @@ mock.module("../src/lib/upstream-retry", () => ({
112113
}));
113114

114115
const { handleResponses } = await import("../src/server/responses");
116+
const { handleResponsesCompact } = await import("../src/server/responses/compact");
115117
type HandleOptions = NonNullable<Parameters<typeof handleResponses>[3]>;
116118

117119
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth/token";
@@ -2299,7 +2301,7 @@ describe("server combo failover 030 activation matrix", () => {
22992301
let backupHits = 0;
23002302
const auth: string[] = [];
23012303
globalThis.fetch = (async (input, init) => {
2302-
const url = input instanceof Request ? input.url : String(input);
2304+
const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) : String(input);
23032305
if (url === XAI_OAUTH_DISCOVERY_URL) {
23042306
return Response.json({ authorization_endpoint: "https://auth.x.ai/oauth/authorize", token_endpoint: TOKEN_ENDPOINT });
23052307
}
@@ -2946,3 +2948,131 @@ describe("cursor conversation continuity across store:false chains", () => {
29462948
expect(seen[1]).toBe(seen[0]);
29472949
});
29482950
});
2951+
2952+
describe("combo compact failover", () => {
2953+
function compactRequest(body: Record<string, unknown>): Request {
2954+
return new Request("http://localhost/v1/responses", {
2955+
method: "POST",
2956+
headers: { "content-type": "application/json" },
2957+
body: JSON.stringify(body),
2958+
});
2959+
}
2960+
2961+
async function postCompactLogged(config: OcxConfig): Promise<Response> {
2962+
const logCtx: RequestLogContext = { model: "", provider: "" };
2963+
const start = Date.now();
2964+
const response = await handleResponsesCompact(compactRequest({
2965+
model: "combo/free",
2966+
stream: false,
2967+
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "earlier turn" }] }],
2968+
}), config, logCtx);
2969+
loggedRequestSequence += 1;
2970+
return responseWithDeferredRequestLog(response, `combo-compact-${loggedRequestSequence}`, start, logCtx);
2971+
}
2972+
2973+
function canonicalPoolConfig(
2974+
targets: Array<{ provider: string; model: string }>,
2975+
backupUrl?: string,
2976+
): { config: OcxConfig } {
2977+
const config = comboConfig({
2978+
openai: {
2979+
adapter: "openai-responses",
2980+
baseUrl: "https://chatgpt.com/backend-api/codex",
2981+
authMode: "key",
2982+
apiKey: "combo-compact-key",
2983+
},
2984+
backup: provider("openai-chat", backupUrl ?? "http://127.0.0.1:9", "key-b"),
2985+
}, targets);
2986+
return { config };
2987+
}
2988+
2989+
test("native-capable first target 429 hops compact to the backup target", async () => {
2990+
const childBodies: Array<Record<string, unknown>> = [];
2991+
const b = serve(async request => {
2992+
childBodies.push(JSON.parse(await request.text()) as Record<string, unknown>);
2993+
return chatStream("compact backup");
2994+
});
2995+
const { config } = canonicalPoolConfig([
2996+
{ provider: "openai", model: "gpt-5.4" },
2997+
{ provider: "backup", model: "m1" },
2998+
], baseUrl(b));
2999+
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
3000+
const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) : String(input);
3001+
if (url.includes("chatgpt.com")) {
3002+
return Response.json({ error: { message: "rate limited" } }, { status: 429 });
3003+
}
3004+
return originalFetch(input as RequestInfo, init);
3005+
}) as typeof fetch;
3006+
3007+
const response = await postCompactLogged(config);
3008+
expect(response.status).toBe(200);
3009+
const json = await response.json() as { output?: unknown[] };
3010+
expect(JSON.stringify(json.output)).toContain("compact backup");
3011+
3012+
// The backup child received the synthetic summarizer turn as SSE, with the
3013+
// summarizer prompt present in its chat wire body.
3014+
expect(childBodies).toHaveLength(1);
3015+
expect(childBodies[0]!.stream).toBe(true);
3016+
expect(JSON.stringify(childBodies[0]!.messages)).toContain("CONTEXT CHECKPOINT COMPACTION");
3017+
3018+
const { log } = await latestAttemptReceipts(config);
3019+
const attempts = log.attempts as Array<Record<string, unknown>>;
3020+
expect(attempts).toHaveLength(2);
3021+
expect(attempts[0]).toMatchObject({
3022+
provider: "openai",
3023+
adapter: "openai-responses",
3024+
status: 429,
3025+
});
3026+
expect(attempts[1]).toMatchObject({ provider: "backup", adapter: "openai-chat", status: 200 });
3027+
});
3028+
3029+
test("combo compact runs the synthetic turn as SSE so a canonical child can serve it", async () => {
3030+
const bodies: Array<Record<string, unknown>> = [];
3031+
const { config } = canonicalPoolConfig([{ provider: "openai", model: "gpt-5.4" }]);
3032+
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
3033+
const url = input instanceof Request ? input.url : String(input);
3034+
if (!url.includes("chatgpt.com")) {
3035+
return originalFetch(input as RequestInfo, init);
3036+
}
3037+
// Only the codex/responses child turn is under test; side probes (e.g. the
3038+
// wham/usage quota check) just get a tolerated non-2xx.
3039+
if (!url.includes("backend-api/codex/responses")) {
3040+
return Response.json({ error: { message: "probe not under test" } }, { status: 403 });
3041+
}
3042+
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
3043+
bodies.push(body);
3044+
// Canonical ChatGPT Responses rejects non-streaming turns; a stream:false child
3045+
// request would strand every canonical-only combo here before the SSE coercion.
3046+
if (body.stream !== true) {
3047+
return Response.json({ error: { message: "non-streaming turns are rejected" } }, { status: 400 });
3048+
}
3049+
const completed = {
3050+
type: "response.completed",
3051+
response: {
3052+
id: "resp_compact",
3053+
status: "completed",
3054+
output: [{ type: "compaction", encrypted_content: encodeCompactionSummary("compact summary") }],
3055+
},
3056+
};
3057+
return new Response([
3058+
"event: response.created",
3059+
'data: {"type":"response.created","response":{"id":"resp_compact","status":"in_progress"}}',
3060+
"",
3061+
`event: ${completed.type}`,
3062+
`data: ${JSON.stringify(completed)}`,
3063+
"",
3064+
"",
3065+
].join("\n"), { headers: { "content-type": "text/event-stream" } });
3066+
}) as typeof fetch;
3067+
3068+
const response = await postCompactLogged(config);
3069+
expect(response.status).toBe(200);
3070+
const json = await response.json() as { output?: unknown[] };
3071+
expect(JSON.stringify(json.output)).toContain("compact summary");
3072+
expect(bodies).toHaveLength(1);
3073+
expect(bodies[0]!.stream).toBe(true);
3074+
// Canonical children keep the native compaction_trigger item — only
3075+
// non-native targets get the summarizer prompt injected (covered above).
3076+
expect(JSON.stringify(bodies[0]!.input)).toContain("compaction_trigger");
3077+
});
3078+
});

0 commit comments

Comments
 (0)