fix(types): resolve context-relay payload resolution and typing issues
- Fix `handoffProviders` and `nextBody` unknown property TS errors in contextHandoff - Correct fallback payload parsing from responses `input` array in combo service
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
payload: HandoffPayload
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
...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] : [];
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<CreateDraftSnapshot>(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.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("<context_handoff>")
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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), /<context_handoff>/);
|
||||
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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user