[codex] fix codex responses continuation across channel drift (#404)

* fix codex responses continuation across channel drift

* fix: clear codex responses continuation fallback on scoped reset

* fix: harden codex continuation store keying
This commit is contained in:
Cita
2026-04-03 11:00:53 +08:00
committed by GitHub
parent b9b553d772
commit af5b62fd8b
3 changed files with 288 additions and 6 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildCodexSessionResponseStoreKey,
clearCodexSessionResponseId,
getCodexSessionResponseId,
resetCodexSessionResponseStore,
setCodexSessionResponseId,
@@ -45,4 +46,84 @@ describe('codexSessionResponseStore', () => {
resetCodexSessionResponseStore();
});
it('falls back to the bare downstream session id across channel scope drift', () => {
resetCodexSessionResponseStore();
const originalScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-drift',
siteId: 10,
accountId: 20,
channelId: 30,
});
const driftedScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-drift',
siteId: 10,
accountId: 21,
channelId: 31,
});
setCodexSessionResponseId(originalScopedKey, 'resp-drift');
expect(getCodexSessionResponseId(originalScopedKey)).toBe('resp-drift');
expect(getCodexSessionResponseId(driftedScopedKey)).toBe('resp-drift');
resetCodexSessionResponseStore();
});
it('does not collapse distinct downstream sessions when scoped session ids contain delimiters', () => {
resetCodexSessionResponseStore();
const delimitedScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-delimited|tail',
siteId: 10,
accountId: 20,
channelId: 30,
});
const delimitedDriftedScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-delimited|tail',
siteId: 10,
accountId: 21,
channelId: 31,
});
const plainScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-delimited',
siteId: 10,
accountId: 22,
channelId: 32,
});
setCodexSessionResponseId(delimitedScopedKey, 'resp-delimited');
setCodexSessionResponseId(plainScopedKey, 'resp-plain');
expect(getCodexSessionResponseId(delimitedDriftedScopedKey)).toBe('resp-delimited');
expect(getCodexSessionResponseId(plainScopedKey)).toBe('resp-plain');
resetCodexSessionResponseStore();
});
it('clears the bare downstream session fallback when removing a scoped continuation id', () => {
resetCodexSessionResponseStore();
const originalScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-clear',
siteId: 10,
accountId: 20,
channelId: 30,
});
const driftedScopedKey = buildCodexSessionResponseStoreKey({
sessionId: 'session-clear',
siteId: 10,
accountId: 21,
channelId: 31,
});
setCodexSessionResponseId(originalScopedKey, 'resp-clear');
clearCodexSessionResponseId(originalScopedKey);
expect(getCodexSessionResponseId(originalScopedKey)).toBeNull();
expect(getCodexSessionResponseId(driftedScopedKey)).toBeNull();
resetCodexSessionResponseStore();
});
});
@@ -2,6 +2,61 @@ const MAX_CODEX_SESSION_RESPONSE_IDS = 10_000;
const codexSessionResponseIds = new Map<string, string>();
const SCOPED_SESSION_SEGMENT_PREFIX = 'session:';
const SCOPED_STORE_KEY_SEGMENT_PATTERN = /^(site|account|channel):\d+$/;
function buildScopedSessionSegment(sessionId: string): string {
return `${SCOPED_SESSION_SEGMENT_PREFIX}${encodeURIComponent(sessionId)}`;
}
function extractScopedSessionSegment(sessionId: string): string {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) return '';
if (normalizedSessionId.startsWith(SCOPED_SESSION_SEGMENT_PREFIX)) {
return normalizedSessionId;
}
const scopedSegments = normalizedSessionId
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
if (scopedSegments.length <= 1) return '';
const sessionSegment = scopedSegments[scopedSegments.length - 1];
if (!sessionSegment.startsWith(SCOPED_SESSION_SEGMENT_PREFIX)) {
return '';
}
const scopeSegments = scopedSegments.slice(0, -1);
if (!scopeSegments.every((segment) => SCOPED_STORE_KEY_SEGMENT_PATTERN.test(segment))) {
return '';
}
return sessionSegment;
}
function getBareSessionStoreKey(sessionId: string): string {
return extractScopedSessionSegment(sessionId);
}
function getFallbackSessionStoreKeys(sessionId: string): string[] {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) return [];
const bareSessionKey = getBareSessionStoreKey(normalizedSessionId);
if (!bareSessionKey) return [];
if (normalizedSessionId === bareSessionKey) return [];
return [bareSessionKey];
}
function getSessionStoreKeys(sessionId: string): string[] {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) return [];
return [
normalizedSessionId,
...getFallbackSessionStoreKeys(normalizedSessionId),
];
}
function normalizeSessionId(sessionId: string): string {
return sessionId.trim();
}
@@ -18,7 +73,7 @@ export function buildCodexSessionResponseStoreKey(input: {
Number.isFinite(input.siteId as number) && Number(input.siteId) > 0 ? `site:${Math.trunc(Number(input.siteId))}` : '',
Number.isFinite(input.accountId as number) && Number(input.accountId) > 0 ? `account:${Math.trunc(Number(input.accountId))}` : '',
Number.isFinite(input.channelId as number) && Number(input.channelId) > 0 ? `channel:${Math.trunc(Number(input.channelId))}` : '',
`session:${normalizedSessionId}`,
buildScopedSessionSegment(normalizedSessionId),
].filter(Boolean);
return parts.join('|');
}
@@ -26,17 +81,32 @@ export function buildCodexSessionResponseStoreKey(input: {
export function getCodexSessionResponseId(sessionId: string): string | null {
const normalized = normalizeSessionId(sessionId);
if (!normalized) return null;
return codexSessionResponseIds.get(normalized) ?? null;
const direct = codexSessionResponseIds.get(normalized);
if (direct) return direct;
for (const fallbackKey of getFallbackSessionStoreKeys(normalized)) {
const fallback = codexSessionResponseIds.get(fallbackKey);
if (fallback) return fallback;
}
return null;
}
export function setCodexSessionResponseId(sessionId: string, responseId: string): void {
const normalizedSessionId = normalizeSessionId(sessionId);
const normalizedResponseId = responseId.trim();
if (!normalizedSessionId || !normalizedResponseId) return;
if (codexSessionResponseIds.has(normalizedSessionId)) {
codexSessionResponseIds.delete(normalizedSessionId);
const keysToWrite = new Set<string>(getSessionStoreKeys(normalizedSessionId));
for (const key of keysToWrite) {
if (codexSessionResponseIds.has(key)) {
codexSessionResponseIds.delete(key);
}
codexSessionResponseIds.set(key, normalizedResponseId);
}
codexSessionResponseIds.set(normalizedSessionId, normalizedResponseId);
while (codexSessionResponseIds.size > MAX_CODEX_SESSION_RESPONSE_IDS) {
const oldestKey = codexSessionResponseIds.keys().next().value;
if (!oldestKey) break;
@@ -47,7 +117,9 @@ export function setCodexSessionResponseId(sessionId: string, responseId: string)
export function clearCodexSessionResponseId(sessionId: string): void {
const normalized = normalizeSessionId(sessionId);
if (!normalized) return;
codexSessionResponseIds.delete(normalized);
for (const key of getSessionStoreKeys(normalized)) {
codexSessionResponseIds.delete(key);
}
}
export function resetCodexSessionResponseStore(): void {