diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 3388d063..c3a5bf75 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1038,13 +1038,19 @@ export async function handleComboChat({ const resetCandidates = [quotaInfo.window5h?.resetAt, quotaInfo.window7d?.resetAt] .filter((value): value is string => typeof value === "string" && value.length > 0) .sort(); + const handoffSourceMessages = + Array.isArray(body?.messages) && body.messages.length > 0 + ? body.messages + : Array.isArray(body?.input) + ? body.input + : []; maybeGenerateHandoff({ sessionId: relayOptions.sessionId, comboName: combo.name, connectionId, percentUsed: quotaInfo.percentUsed, - messages: Array.isArray(body?.messages) ? body.messages : [], + messages: handoffSourceMessages, model: modelStr, expiresAt: resetCandidates[0] || null, config: relayConfig, diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index 61e9875e..18b5969d 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -63,7 +63,7 @@ export function resolveContextRelayConfig( const rawMaxMessages = Number(config?.maxMessagesForSummary); const hasExplicitProviders = Array.isArray(config?.handoffProviders); const handoffProviders = hasExplicitProviders - ? config?.handoffProviders + ? (config?.handoffProviders as unknown[]) .map((item) => (typeof item === "string" ? item.trim().toLowerCase() : "")) .filter(Boolean) : ["codex"]; @@ -370,9 +370,34 @@ export function injectHandoffIntoBody( body: Record, payload: HandoffPayload ): Record { + const handoffContent = buildHandoffSystemMessage(payload); + const isResponsesRequest = + Object.prototype.hasOwnProperty.call(body, "input") || + Object.prototype.hasOwnProperty.call(body, "instructions"); + + if (isResponsesRequest) { + const existingInstructions = + typeof body.instructions === "string" && body.instructions.trim().length > 0 + ? body.instructions + : ""; + const nextBody: Record = { + ...body, + instructions: existingInstructions + ? `${handoffContent}\n\n${existingInstructions}` + : handoffContent, + }; + + if (Array.isArray(nextBody.messages) && nextBody.messages.length === 0) { + const { messages: _messages, ...rest } = nextBody; + return rest; + } + + return nextBody; + } + const handoffMessage = { role: "system", - content: buildHandoffSystemMessage(payload), + content: handoffContent, }; const messages = Array.isArray(body.messages) ? [...body.messages] : []; diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 17f278ab..8270d64c 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1210,9 +1210,34 @@ function TestResultsView({ results }) { // Combo Form Modal // ───────────────────────────────────────────── function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { + type CreateDraftSnapshot = { + name: string; + models: unknown[]; + strategy: string; + config: Record; + showAdvanced: boolean; + nameError: string; + agentSystemMessage: string; + agentToolFilter: string; + agentContextCache: boolean; + }; + + const getEmptyCreateDraftSnapshot = (): CreateDraftSnapshot => ({ + name: "", + models: [], + strategy: "priority", + config: {}, + showAdvanced: false, + nameError: "", + agentSystemMessage: "", + agentToolFilter: "", + agentContextCache: false, + }); + const t = useTranslations("combos"); const tc = useTranslations("common"); const notify = useNotificationStore(); + const createDraftStateRef = useRef(getEmptyCreateDraftSnapshot()); const [name, setName] = useState(combo?.name || ""); const [models, setModels] = useState(() => { return (combo?.models || []).map((m) => normalizeModelEntry(m)); @@ -1260,6 +1285,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { [setAgentContextCache] ); + useEffect(() => { + createDraftStateRef.current = { + name, + models, + strategy, + config, + showAdvanced, + nameError, + agentSystemMessage, + agentToolFilter, + agentContextCache, + }; + }, [ + name, + models, + strategy, + config, + showAdvanced, + nameError, + agentSystemMessage, + agentToolFilter, + agentContextCache, + ]); + // DnD state const hasPricingForModel = useCallback( (modelValue) => { @@ -1405,17 +1454,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { }; } + createDraftStateRef.current = getEmptyCreateDraftSnapshot(); + resetFormForCombo(null, null); + const loadDefaults = async () => { try { const response = await fetch("/api/settings/combo-defaults"); const data = response.ok ? await response.json() : {}; - if (!cancelled) { + const draft = createDraftStateRef.current; + const isPristineDraft = + draft.name.trim().length === 0 && + draft.models.length === 0 && + draft.strategy === "priority" && + Object.keys(draft.config || {}).length === 0 && + draft.showAdvanced === false && + draft.nameError.length === 0 && + draft.agentSystemMessage.length === 0 && + draft.agentToolFilter.length === 0 && + draft.agentContextCache === false; + + if (!cancelled && isPristineDraft) { resetFormForCombo(null, data.comboDefaults || null); } } catch { - if (!cancelled) { - resetFormForCombo(null, null); - } + // Keep the blank create form if defaults fail to load. } }; diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index ab79d6ac..9fa80b8c 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -509,8 +509,7 @@ async function handleSingleModelChat( comboStrategy === "context-relay" && comboName && runtimeOptions.sessionId && - body?._omnirouteSkipContextRelay !== true && - !excludeConnectionId + body?._omnirouteSkipContextRelay !== true ) { const handoff = getHandoff(runtimeOptions.sessionId, comboName); if (handoff && handoff.fromAccount !== credentials.connectionId) { diff --git a/tests/integration/_chatPipelineHarness.mjs b/tests/integration/_chatPipelineHarness.mjs index ec0a9bb8..d09ebe3d 100644 --- a/tests/integration/_chatPipelineHarness.mjs +++ b/tests/integration/_chatPipelineHarness.mjs @@ -203,6 +203,8 @@ export async function createChatPipelineHarness(prefix) { async function resetStorage() { globalThis.fetch = originalFetch; clearInflight(); + idempotencyLayerModule.clearIdempotency(); + semanticCacheModule.clearCache(); resetAllAvailability(); resetAllCircuitBreakers(); apiKeysDb.resetApiKeyState(); @@ -220,6 +222,8 @@ export async function createChatPipelineHarness(prefix) { BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs; globalThis.fetch = originalFetch; clearInflight(); + idempotencyLayerModule.clearIdempotency(); + semanticCacheModule.clearCache(); clearSkillState(); resetAllAvailability(); resetAllCircuitBreakers(); diff --git a/tests/unit/chat-context-relay.test.mjs b/tests/unit/chat-context-relay.test.mjs index ca86ccba..7ed5b94a 100644 --- a/tests/unit/chat-context-relay.test.mjs +++ b/tests/unit/chat-context-relay.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.mjs"; const harness = await createChatPipelineHarness("chat-context-relay"); -const { buildRequest, combosDb, handleChat, resetStorage, waitFor } = harness; +const { BaseExecutor, buildRequest, combosDb, handleChat, resetStorage, waitFor } = harness; const providersDb = await import("../../src/lib/db/providers.ts"); const handoffDb = await import("../../src/lib/db/contextHandoffs.ts"); @@ -195,3 +195,155 @@ test("handleChat generates and injects context-relay handoffs across Codex accou assert.equal(handoffDb.getHandoff(sessionId, "relay-combo"), null); await new Promise((resolve) => setTimeout(resolve, 50)); }); + +test("handleChat injects context-relay handoffs during live failover for Responses-native Codex requests", async () => { + BaseExecutor.RETRY_CONFIG.delayMs = 1; + + const primary = await seedCodexOAuthConnection({ + name: "codex-live-a", + email: "relay-live-a@example.com", + accessToken: "token-a", + refreshToken: "refresh-a", + workspaceId: "ws-a", + priority: 1, + }); + await seedCodexOAuthConnection({ + name: "codex-live-b", + email: "relay-live-b@example.com", + accessToken: "token-b", + refreshToken: "refresh-b", + workspaceId: "ws-b", + priority: 2, + }); + + await combosDb.createCombo({ + name: "relay-live-combo", + strategy: "context-relay", + config: { + maxRetries: 0, + retryDelayMs: 0, + handoffThreshold: 0.85, + maxMessagesForSummary: 12, + }, + models: ["codex/gpt-5.4"], + }); + + const upstreamBodies = []; + let primaryRequestCount = 0; + + globalThis.fetch = async (url, init = {}) => { + const urlStr = String(url); + const headers = Object.fromEntries(new Headers(init.headers || {}).entries()); + const authHeader = headers.authorization || headers.Authorization || ""; + + if (urlStr.includes("/backend-api/wham/usage")) { + return authHeader === "Bearer token-a" ? buildQuotaResponse(87) : buildQuotaResponse(20); + } + + const body = init.body ? JSON.parse(String(init.body)) : {}; + const serializedBody = JSON.stringify(body); + const isSummaryRequest = + body._omnirouteInternalRequest === "context-handoff" || + serializedBody.includes("You are a context summarizer"); + + if (isSummaryRequest) { + return buildResponsesResponse( + JSON.stringify({ + summary: "Carry over the Responses-native Codex session", + keyDecisions: ["Keep the request in /responses format"], + taskProgress: "Continue after the first account is exhausted", + activeEntities: ["src/sse/handlers/chat.ts", "open-sse/services/contextHandoff.ts"], + }), + "gpt-5.4" + ); + } + + upstreamBodies.push({ authHeader, body, serializedBody }); + + if (authHeader === "Bearer token-a") { + primaryRequestCount += 1; + if (primaryRequestCount >= 2) { + return new Response(JSON.stringify({ error: { message: "quota exceeded" } }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + } + } + + return buildResponsesResponse("relay-success", "gpt-5.4"); + }; + + const firstResponse = await handleChat( + buildRequest({ + headers: { + "X-Session-Id": "relay-live-session", + "X-OmniRoute-No-Cache": "true", + }, + body: { + model: "relay-live-combo", + stream: false, + instructions: "Preserve the original Responses instructions", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep implementing the new combo" }], + }, + ], + }, + }) + ); + + assert.equal(firstResponse.status, 200); + + const sessionId = "ext:relay-live-session"; + const savedHandoff = await waitFor( + () => handoffDb.getHandoff(sessionId, "relay-live-combo"), + 2000 + ); + assert.ok(savedHandoff); + assert.equal(savedHandoff.fromAccount, primary.id); + + const secondResponse = await handleChat( + buildRequest({ + headers: { + "X-Session-Id": "relay-live-session", + "X-OmniRoute-No-Cache": "true", + }, + body: { + model: "relay-live-combo", + stream: false, + instructions: "Continue with the current task", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue from where you left off" }], + }, + ], + }, + }) + ); + + assert.equal(secondResponse.status, 200); + + const relayedSecondaryCall = upstreamBodies.find( + (call) => + call.authHeader === "Bearer token-b" && + typeof call.body.instructions === "string" && + call.body.instructions.includes("") + ); + + assert.ok(relayedSecondaryCall); + assert.equal("messages" in relayedSecondaryCall.body, false); + assert.deepEqual( + relayedSecondaryCall.body.input[0].content[0].text, + "Continue from where you left off" + ); + assert.match( + relayedSecondaryCall.body.instructions, + /Carry over the Responses-native Codex session/ + ); + assert.match(relayedSecondaryCall.body.instructions, /Continue with the current task/); + assert.equal(handoffDb.getHandoff(sessionId, "relay-live-combo"), null); +}); diff --git a/tests/unit/context-handoff.test.mjs b/tests/unit/context-handoff.test.mjs index 34212219..99e79a88 100644 --- a/tests/unit/context-handoff.test.mjs +++ b/tests/unit/context-handoff.test.mjs @@ -48,8 +48,8 @@ test("buildHandoffSystemMessage and injectHandoffIntoBody preserve existing hist messageCount: 42, model: "codex/gpt-5.4", warningThresholdPct: 0.85, - generatedAt: "2026-04-08T12:00:00.000Z", - expiresAt: "2026-04-08T17:00:00.000Z", + generatedAt: "2099-04-08T12:00:00.000Z", + expiresAt: "2099-04-08T17:00:00.000Z", }; const body = { messages: [ @@ -69,6 +69,40 @@ test("buildHandoffSystemMessage and injectHandoffIntoBody preserve existing hist assert.equal(body.messages.length, 2); }); +test("injectHandoffIntoBody preserves Responses API shape for native Codex requests", () => { + const payload = { + sessionId: "sess-1", + comboName: "relay-combo", + fromAccount: "conn-a", + summary: "Keep the current plan and continue seamlessly", + keyDecisions: ["prefer Responses-native payloads"], + taskProgress: "Need to carry state across account switches", + activeEntities: ["chat.ts", "contextHandoff.ts"], + messageCount: 8, + model: "codex/gpt-5.4", + warningThresholdPct: 0.85, + generatedAt: "2099-04-08T12:00:00.000Z", + expiresAt: "2099-04-08T17:00:00.000Z", + }; + const body = { + instructions: "Original instructions", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue" }], + }, + ], + }; + + const injected = contextHandoff.injectHandoffIntoBody(body, payload); + + assert.equal("messages" in injected, false); + assert.deepEqual(injected.input, body.input); + assert.match(String(injected.instructions), //); + assert.match(String(injected.instructions), /Original instructions/); +}); + test("parseHandoffJSON accepts fenced JSON and normalizes fields", () => { const parsed = contextHandoff.parseHandoffJSON(`\`\`\`json {"summary":" Ready to continue ","keyDecisions":["A","B"],"taskProgress":"Pending tests","activeEntities":["file.ts","combo.ts"]} @@ -131,7 +165,7 @@ test("maybeGenerateHandoff persists a structured handoff once the threshold is r { role: "assistant", content: "Working on it" }, ], model: "codex/gpt-5.4", - expiresAt: "2026-04-08T17:00:00.000Z", + expiresAt: "2099-04-08T17:00:00.000Z", handleSingleModel: async (body, modelStr) => { calls.push({ body, modelStr }); return new Response( @@ -308,7 +342,7 @@ test("context handoff DB module upserts and deletes active handoffs", () => { messageCount: 3, model: "codex/gpt-5.4", warningThresholdPct: 0.85, - generatedAt: "2026-04-08T10:00:00.000Z", + generatedAt: "2099-04-08T10:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", }); handoffDb.upsertHandoff({ @@ -322,7 +356,7 @@ test("context handoff DB module upserts and deletes active handoffs", () => { messageCount: 4, model: "codex/gpt-5.4", warningThresholdPct: 0.86, - generatedAt: "2026-04-08T11:00:00.000Z", + generatedAt: "2099-04-08T11:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", }); diff --git a/tests/unit/oauth-providers-config.test.mjs b/tests/unit/oauth-providers-config.test.mjs index 94cfbab6..5ecb3355 100644 --- a/tests/unit/oauth-providers-config.test.mjs +++ b/tests/unit/oauth-providers-config.test.mjs @@ -18,6 +18,7 @@ import { QODER_CONFIG, QWEN_CONFIG, } from "../../src/lib/oauth/constants/oauth.ts"; +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; const originalFetch = globalThis.fetch; @@ -277,6 +278,12 @@ test("provider-specific config shapes remain valid for special cases", () => { assert.equal(typeof KILOCODE_CONFIG.pollUrlBase, "string"); }); +test("Gemini OAuth defaults do not hardcode a client secret fallback", () => { + assert.equal(GEMINI_CONFIG.clientSecret, process.env.GEMINI_OAUTH_CLIENT_SECRET || ""); + assert.equal(REGISTRY.gemini.oauth.clientSecretDefault, ""); + assert.equal(REGISTRY["gemini-cli"].oauth.clientSecretDefault, ""); +}); + test("Qoder remains a safe special case when browser OAuth is disabled", () => { if (!QODER_CONFIG.enabled) { assert.equal(