File size: 15,823 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
// 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)"
  );
});