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

368 lines
16 KiB
TypeScript

// tests/integration/combo-matrix/context-relay-codex.test.ts
//
// Deterministic in-process test for context-relay CODEX-SPECIFIC HANDOFF
// (combo.ts ~2143-2183): the quota-aware handoff that fires after a successful
// codex response when the quota exceeds the threshold.
//
// Code path:
// if (strategy === "context-relay" && relayOptions?.sessionId && relayConfig &&
// relayConfig.handoffProviders.includes(provider) && provider === "codex") {
// const connectionId = getSessionConnection(relayOptions.sessionId);
// if (connectionId) {
// const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null);
// if (quotaInfo) { ... maybeGenerateHandoff({ ..., expiresAt: resetCandidates[0] }); }
// }
// }
//
// PRIMARY observable: getHandoff(sessionId, comboName).expiresAt === session-window
// reset time from the codex quota response. This is the CODEX-SPECIFIC proof:
// the universal handoff path does NOT set expiresAt from a codex quota fetch.
//
// SECONDARY observables:
// - The codex usage URL was fetched (proves the quota path ran).
// - The handoff record has a non-empty .summary.
//
// CONTROL: combo targeting openai (not codex) → codex block NEVER runs →
// codex usage URL is never fetched → expiresAt from quota doesn't appear.
//
// Seams used:
// - registerCodexConnection / unregisterCodexConnection (codexQuotaFetcher.ts)
// - clearSessions (sessionManager.ts) — cleanup between tests
// - buildCodexResponsesSse helper (mirrors codex-stream-false.test.ts)
// - Custom fetch mock distinguishes quota URL / summary dispatch / main dispatch
import test from "node:test";
import assert from "node:assert/strict";
import { createComboRoutingHarness } from "../_comboRoutingHarness.ts";
import {
registerCodexConnection,
unregisterCodexConnection,
} from "../../../open-sse/services/codexQuotaFetcher.ts";
import { clearSessions } from "../../../open-sse/services/sessionManager.ts";
// ── Harness setup ─────────────────────────────────────────────────────────────
// Each harness owns an isolated DATA_DIR so DB handles don't clash.
const h = await createComboRoutingHarness("combo-relay-codex");
const {
BaseExecutor,
combosDb,
handleChat,
buildRequest,
seedConnection,
resetStorage,
buildOpenAIResponse,
waitFor,
toPlainHeaders,
} = h;
// Import DB helpers AFTER harness creation so they share the same DB instance
// (DATA_DIR is set by the harness before any import triggers DB init).
const { getHandoff } = await import("../../../src/lib/db/contextHandoffs.ts");
// ── Constants ─────────────────────────────────────────────────────────────────
const CODEX_COMBO_NAME = "m-relay-codex-quota";
const SESSION_HEADER_VALUE = "relay-codex-quota-001";
const SESSION_ID = `ext:${SESSION_HEADER_VALUE}`;
// Codex endpoint URLs (must match codexQuotaFetcher.ts and the executor config).
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
const CODEX_RESPONSES_HOST = "chatgpt.com/backend-api/codex/responses";
// Summary JSON that parseHandoffJSON will successfully parse.
const CODEX_SUMMARY_JSON = JSON.stringify({
summary:
"User is implementing a TypeScript context-relay codex quota-handoff test using TDD.",
keyDecisions: ["codex provider selected", "quota threshold at 90%"],
taskProgress: "writing deterministic integration test for codex handoff",
activeEntities: ["combo.ts", "codexQuotaFetcher.ts", "contextHandoff.ts"],
});
// ── SSE builder (mirrors codex-stream-false.test.ts) ─────────────────────────
function buildCodexResponsesSse(text = "codex assistant reply") {
return new Response(
[
"event: response.created",
'data: {"type":"response.created","response":{"id":"resp_codex_1","model":"gpt-5.3-codex","status":"in_progress","output":[]}}',
"",
"event: response.output_text.delta",
`data: ${JSON.stringify({
type: "response.output_text.delta",
output_index: 0,
delta: text,
})}`,
"",
"event: response.completed",
`data: ${JSON.stringify({
type: "response.completed",
response: {
id: "resp_codex_1",
object: "response",
model: "gpt-5.3-codex",
status: "completed",
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text }],
},
],
usage: { input_tokens: 6, output_tokens: 4 },
},
})}`,
"",
"data: [DONE]",
"",
].join("\n"),
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
}
);
}
// ── Quota response builder ────────────────────────────────────────────────────
// Produces a codex usage API response that parseCodexUsageResponse accepts.
// primary_window → session → percentUsed = 0.90 (90%) — triggers handoff (threshold 0.85)
// secondary_window → weekly → percentUsed = 0.85 (85%) — below threshold for weekly
// Both are below HANDOFF_EXHAUSTION_THRESHOLD (0.95) so maybeGenerateHandoff proceeds.
function buildCodexUsageBody(
sessionResetAtUnix: number,
weeklyResetAtUnix: number
): Record<string, unknown> {
return {
rate_limit: {
primary_window: { used_percent: 90, reset_at: sessionResetAtUnix },
secondary_window: { used_percent: 85, reset_at: weeklyResetAtUnix },
},
};
}
// ── Request builder ───────────────────────────────────────────────────────────
function codexRequest(withSessionId = true) {
return buildRequest({
headers: withSessionId ? { "x-session-id": SESSION_HEADER_VALUE } : {},
body: {
model: CODEX_COMBO_NAME,
stream: false,
messages: [{ role: "user", content: "Write a TypeScript hello world." }],
},
});
}
// ── Fetch mock ────────────────────────────────────────────────────────────────
// Handles three URL classes:
// 1. CODEX_USAGE_URL → codex quota JSON
// 2. CODEX_RESPONSES_HOST (call 1) → main request Responses API SSE
// 3. CODEX_RESPONSES_HOST (call 2) → summary request Responses API SSE (with CODEX_SUMMARY_JSON)
//
// The second codex-responses call is the handoff summary generation, identified by
// call count (the first is always the main request).
function installCodexHandoffFetch(
sessionResetAtUnix: number,
weeklyResetAtUnix: number,
seenUrls: string[]
) {
let codexResponsesCallCount = 0;
globalThis.fetch = async (url: unknown, init: unknown = {}) => {
const u = String(url);
seenUrls.push(u);
const headers =
init != null && typeof init === "object"
? toPlainHeaders((init as Record<string, unknown>).headers)
: {};
void headers; // recorded for debugging; not needed in assertions here
if (u === CODEX_USAGE_URL) {
// Codex quota endpoint — return structured usage JSON.
return new Response(
JSON.stringify(buildCodexUsageBody(sessionResetAtUnix, weeklyResetAtUnix)),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (u.includes(CODEX_RESPONSES_HOST)) {
codexResponsesCallCount++;
if (codexResponsesCallCount === 1) {
// Main codex request — return a successful Responses API SSE.
return buildCodexResponsesSse("codex assistant reply ok");
}
// Subsequent call(s) are from the handoff summary generation.
// Return Responses API SSE whose output_text is CODEX_SUMMARY_JSON so that
// generateHandoffAsync → parseHandoffJSON succeeds.
return buildCodexResponsesSse(CODEX_SUMMARY_JSON);
}
// Fallback — should not be reached in these tests.
return new Response(JSON.stringify({ error: { message: "unexpected URL in test" } }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
};
}
// ── Lifecycle hooks ───────────────────────────────────────────────────────────
test.beforeEach(async () => {
BaseExecutor.RETRY_CONFIG.delayMs = 0;
await resetStorage();
});
test.afterEach(async () => {
BaseExecutor.RETRY_CONFIG.delayMs = h.originalRetryDelayMs;
// Clear in-memory session state so test 2 doesn't see stale connections from test 1.
clearSessions();
await resetStorage();
});
test.after(async () => {
await h.cleanup();
});
// ── Test 1 (PRIMARY): codex quota handoff fires ───────────────────────────────
//
// Flow:
// 1. Seed a codex DB connection + register with accessToken.
// 2. Create context-relay combo → codex.
// 3. Send request with x-session-id header.
// 4. During request processing, chat.ts calls touchSession(sessionId, codexConnId).
// 5. After the successful codex response, combo.ts enters the codex block:
// - getSessionConnection → codexConnId (set in step 4).
// - fetchCodexQuota(codexConnId) → hits our mock → returns 90% usage.
// - resetCandidates[0] = session-window resetAt ISO string.
// - maybeGenerateHandoff({ ..., expiresAt: sessionResetISO }) fires via setImmediate.
// 6. generateHandoffAsync dispatches a second codex fetch for the summary.
// 7. upsertHandoff writes to DB with expiresAt = sessionResetISO.
//
// Assertion: handoff.expiresAt === sessionResetISO (codex-specific, not TTL-derived).
test("context-relay codex quota handoff: fires and expiresAt matches session-window reset from quota", async () => {
// 1. Seed codex connection.
const conn = await seedConnection("codex", { apiKey: "sk-codex-handoff-test-1" });
const codexConnId = conn.id;
// 2. Register connection meta so fetchCodexQuota can find credentials.
// chat.ts also calls registerCodexConnection during request processing for OAuth
// tokens; for API-key connections the accessToken may not be set there, so we
// pre-register with the API key as the token. This is the documented test seam.
registerCodexConnection(codexConnId, { accessToken: "sk-codex-handoff-test-1" });
// 3. Create a context-relay combo targeting codex.
// Default handoffProviders: ["codex"] and handoffThreshold: 0.85 apply.
await combosDb.createCombo({
name: CODEX_COMBO_NAME,
strategy: "context-relay",
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
models: [
{ id: "rc-codex-1", kind: "model", providerId: "codex", model: "gpt-5.3-codex" },
],
});
// 4. Compute quota reset times (future timestamps).
const sessionResetAtUnix = Math.floor((Date.now() + 5 * 60 * 60 * 1000) / 1000); // +5h
const weeklyResetAtUnix = Math.floor((Date.now() + 7 * 24 * 60 * 60 * 1000) / 1000); // +7d
// Expected expiresAt: parseWindowReset converts reset_at (unix seconds) → ISO string.
// resetCandidates[0] = earliest ISO string = session window reset (5h < 7d).
const expectedExpiresAt = new Date(sessionResetAtUnix * 1000).toISOString();
// 5. Install fetch mock and send request.
const seenUrls: string[] = [];
installCodexHandoffFetch(sessionResetAtUnix, weeklyResetAtUnix, seenUrls);
const r = await handleChat(codexRequest(true));
assert.equal(r.status, 200, "main codex request must return 200");
// 6. Wait for setImmediate → generateHandoffAsync → upsertHandoff (up to 3s).
const handoff = await waitFor(() => getHandoff(SESSION_ID, CODEX_COMBO_NAME), 3000);
// ── Primary assertion: handoff record exists ──────────────────────────────
assert.ok(
handoff !== null,
"codex quota handoff record must be written to DB when quota ≥ threshold"
);
// ── CODEX-SPECIFIC proof: expiresAt == session-window reset from quota ────
// The universal handoff path never calls fetchCodexQuota, so it cannot produce
// this specific expiresAt value. The value below can only come from the codex block.
assert.equal(
handoff!.expiresAt,
expectedExpiresAt,
`handoff.expiresAt must equal the session-window reset from codex quota (${expectedExpiresAt}); ` +
`got ${handoff!.expiresAt}`
);
// ── Secondary assertion: codex usage URL was fetched ─────────────────────
assert.ok(
seenUrls.includes(CODEX_USAGE_URL),
`codex usage URL must have been fetched to produce the expiresAt; seen URLs: ${JSON.stringify(seenUrls)}`
);
// ── Secondary assertion: summary was generated (non-empty) ───────────────
assert.ok(
typeof handoff!.summary === "string" && handoff!.summary.length > 0,
`handoff.summary must be non-empty; got ${JSON.stringify(handoff!.summary)}`
);
// ── Cleanup ───────────────────────────────────────────────────────────────
unregisterCodexConnection(codexConnId);
});
// ── Test 2 (CONTROL): non-codex provider never triggers the codex block ───────
//
// The codex block gates on `provider === "codex"`. A combo targeting openai
// will never enter it, so the codex usage URL is never fetched and there is no
// quota-derived expiresAt in the DB.
//
// This proves the primary test is not trivially green (i.e., the block is
// actually gated on `provider === "codex"`).
test("context-relay codex quota handoff: does NOT fire when provider is openai (control)", async () => {
// Seed openai (not codex).
await seedConnection("openai", { apiKey: "sk-openai-control-no-codex-block" });
await combosDb.createCombo({
name: CODEX_COMBO_NAME,
strategy: "context-relay",
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
models: [
{ id: "rc-openai-ctrl", kind: "model", providerId: "openai", model: "gpt-4o-mini" },
],
});
const seenUrls: string[] = [];
globalThis.fetch = async (url: unknown, _init: unknown = {}) => {
seenUrls.push(String(url));
return buildOpenAIResponse("assistant reply ok");
};
const r = await handleChat(codexRequest(true));
assert.equal(r.status, 200, "openai request must return 200");
// Give setImmediate time to fire if the block were incorrectly entered.
await new Promise((res) => setTimeout(res, 400));
// The codex block requires provider === "codex", so it never runs for openai.
// The codex usage URL must NOT have been fetched.
assert.ok(
!seenUrls.includes(CODEX_USAGE_URL),
`codex usage URL must NOT be fetched for openai provider; seen: ${JSON.stringify(seenUrls)}`
);
// No codex quota handoff record in DB.
// (The universal handoff also does not fire because no prior model is seeded,
// so getLastSessionModel returns null → no model switch detected.)
const handoff = getHandoff(SESSION_ID, CODEX_COMBO_NAME);
assert.equal(
handoff,
null,
"no handoff record must exist when provider is openai (codex block never entered)"
);
});