[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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user