🐛 fix(codex): parse retry time in stated timezone (#15758)

* 🐛 fix(codex): parse retry time in stated timezone

* 🐛 fix: enable remote git review panel

* 🐛 fix(codex): preserve adjacent retry meridiem
This commit is contained in:
Arvin Xu
2026-06-13 16:32:35 +08:00
committed by GitHub
parent 531900cf70
commit 480a2979e1
4 changed files with 363 additions and 10 deletions
@@ -141,6 +141,96 @@ describe('CodexAdapter', () => {
}
});
it('preserves adjacent Codex retry meridiem parsing', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 5, 13, 15, 9, 27));
try {
const adapter = new CodexAdapter();
const message =
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at 3:10PM.";
const expectedResetAt = Math.floor(new Date(2026, 5, 13, 15, 10).getTime() / 1000);
adapter.adapt({ type: 'turn.started' });
const events = adapter.adapt({
message,
type: 'error',
});
expect(events[1].data).toMatchObject({
code: 'rate_limit',
rateLimitInfo: {
resetsAt: expectedResetAt,
status: 'rejected',
},
});
} finally {
vi.useRealTimers();
}
});
it('parses Codex retry metadata in the timezone stated by the error message', () => {
const previousTimezone = process.env.TZ;
process.env.TZ = 'Asia/Shanghai';
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-13T03:09:27+08:00'));
try {
const adapter = new CodexAdapter();
const message =
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at 3:10 AM (UTC).";
const expectedResetAt = Math.floor(new Date('2026-06-13T03:10:00Z').getTime() / 1000);
adapter.adapt({ type: 'turn.started' });
const events = adapter.adapt({
message,
type: 'error',
});
expect(events[1].data).toMatchObject({
code: 'rate_limit',
rateLimitInfo: {
resetsAt: expectedResetAt,
status: 'rejected',
},
});
} finally {
vi.useRealTimers();
if (previousTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = previousTimezone;
}
}
});
it('omits Codex retry timestamps when the stated timezone cannot be interpreted', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-13T03:09:27Z'));
try {
const adapter = new CodexAdapter();
const message =
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at 3:10 AM (Codex HQ).";
adapter.adapt({ type: 'turn.started' });
const events = adapter.adapt({
message,
type: 'error',
});
expect(events[1].data).toMatchObject({
code: 'rate_limit',
rateLimitInfo: {
status: 'rejected',
},
});
expect(events[1].data.rateLimitInfo).not.toHaveProperty('resetsAt');
} finally {
vi.useRealTimers();
}
});
it('deduplicates the following turn.failed after a Codex JSONL error event', () => {
const adapter = new CodexAdapter();
@@ -25,7 +25,8 @@ const CODEX_USER_RATE_LIMIT_PATTERNS = [
/\busage limit\b/i,
] as const;
const CODEX_RETRY_AT_PATTERN = /\btry again at\s+(\d{1,2})(?::(\d{2}))?\s*(AM|PM)?\b/i;
const CODEX_RETRY_AT_PATTERN =
/\btry again at\s+(\d{1,2})(?::(\d{2}))?(?:(AM|PM)|\s+(AM|PM))?(?:\s+\(([^()]+)\))?/i;
interface CodexBaseItem {
id: string;
@@ -92,6 +93,15 @@ type CodexToolItem =
| CodexMcpToolCallItem
| CodexTodoListItem;
interface ZonedDateTimeParts {
day: number;
hour: number;
minute: number;
month: number;
second: number;
year: number;
}
const isCommandExecutionItem = (item: CodexToolItem): item is CodexCommandExecutionItem =>
item.type === CODEX_COMMAND_API;
@@ -505,13 +515,155 @@ const getCodexTerminalErrorStderr = (raw: any): string | undefined => {
);
};
const getZonedDateTimeParts = (date: Date, timeZone: string): ZonedDateTimeParts | undefined => {
try {
const parts = new Intl.DateTimeFormat('en-US', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone,
year: 'numeric',
}).formatToParts(date);
const values = new Map(parts.map(({ type, value }) => [type, value]));
const zonedParts = {
day: Number(values.get('day')),
hour: Number(values.get('hour')),
minute: Number(values.get('minute')),
month: Number(values.get('month')),
second: Number(values.get('second')),
year: Number(values.get('year')),
};
if (Object.values(zonedParts).some((value) => !Number.isInteger(value))) return;
return zonedParts;
} catch {
return;
}
};
const getTimeZoneOffsetMs = (date: Date, timeZone: string): number | undefined => {
const parts = getZonedDateTimeParts(date, timeZone);
if (!parts) return;
const zonedAsUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return zonedAsUtc - date.getTime();
};
const matchesZonedWallClock = (
date: Date,
timeZone: string,
expected: ZonedDateTimeParts,
): boolean => {
const actual = getZonedDateTimeParts(date, timeZone);
return (
!!actual &&
actual.year === expected.year &&
actual.month === expected.month &&
actual.day === expected.day &&
actual.hour === expected.hour &&
actual.minute === expected.minute &&
actual.second === expected.second
);
};
const zonedWallClockToEpochMs = (
parts: ZonedDateTimeParts,
timeZone: string,
): number | undefined => {
const utcGuess = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
const initialOffset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone);
if (initialOffset === undefined) return;
let epochMs = utcGuess - initialOffset;
const adjustedOffset = getTimeZoneOffsetMs(new Date(epochMs), timeZone);
if (adjustedOffset === undefined) return;
epochMs = utcGuess - adjustedOffset;
if (!matchesZonedWallClock(new Date(epochMs), timeZone, parts)) return;
return epochMs;
};
const addDaysToZonedDate = (
parts: Pick<ZonedDateTimeParts, 'day' | 'month' | 'year'>,
days: number,
) => {
const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days));
return {
day: date.getUTCDate(),
month: date.getUTCMonth() + 1,
year: date.getUTCFullYear(),
};
};
const parseCodexRetryAtInTimeZone = (
hour: number,
minute: number,
timeZone: string,
now: Date,
): number | undefined => {
const nowParts = getZonedDateTimeParts(now, timeZone);
if (!nowParts) return;
let retryAt = zonedWallClockToEpochMs(
{
day: nowParts.day,
hour,
minute,
month: nowParts.month,
second: 0,
year: nowParts.year,
},
timeZone,
);
if (retryAt === undefined) return;
if (retryAt <= now.getTime()) {
const nextDate = addDaysToZonedDate(nowParts, 1);
retryAt = zonedWallClockToEpochMs(
{
...nextDate,
hour,
minute,
second: 0,
},
timeZone,
);
}
return retryAt === undefined ? undefined : Math.floor(retryAt / 1000);
};
const parseCodexRetryAt = (message: string, now = new Date()): number | undefined => {
const match = CODEX_RETRY_AT_PATTERN.exec(message);
if (!match) return;
const hour = Number(match[1]);
const minute = match[2] ? Number(match[2]) : 0;
const meridiem = match[3]?.toUpperCase();
const [, rawHour, rawMinute, rawAdjacentMeridiem, rawSpacedMeridiem, rawTimeZone] = match;
const hour = Number(rawHour);
const minute = rawMinute ? Number(rawMinute) : 0;
const meridiem = (rawAdjacentMeridiem || rawSpacedMeridiem)?.toUpperCase();
const timeZone = rawTimeZone?.trim();
if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute < 0 || minute > 59) {
return;
@@ -525,6 +677,10 @@ const parseCodexRetryAt = (message: string, now = new Date()): number | undefine
return;
}
if (timeZone) {
return parseCodexRetryAtInTimeZone(normalizedHour, minute, timeZone, now);
}
const resetAt = new Date(now);
resetAt.setHours(normalizedHour, minute, 0, 0);
if (resetAt.getTime() <= now.getTime()) {
@@ -153,7 +153,6 @@ interface GitStatusProps {
const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
const { t } = useTranslation('device');
const local = !deviceId;
// Transport (Electron IPC vs device RPC) is decided inside the service; the
// component just reads, identically for local and remote.
const { data, mutate } = useFetchGitInfo(deviceId, path, isGithub);
@@ -365,11 +364,7 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
const diffNode = (() => {
if (!hasChanges || !workingStatus) return null;
const diffButton = (
<div
className={styles.trigger}
role={local ? 'button' : undefined}
onClick={local ? handleToggleReview : undefined}
>
<div className={styles.trigger} role="button" onClick={handleToggleReview}>
<span className={styles.diffStat}>
{workingStatus.added > 0 && (
<span className={styles.diffStatAdded}>+{workingStatus.added}</span>
@@ -0,0 +1,112 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import GitStatus from '../GitStatus';
const globalStoreMock = vi.hoisted(() => ({
setWorkingSidebarTab: vi.fn(),
status: {
showRightPanel: false,
workingSidebarTab: 'resources',
},
toggleRightPanel: vi.fn(),
}));
const gitHookMocks = vi.hoisted(() => ({
mutateAheadBehind: vi.fn(),
mutateGitInfo: vi.fn(),
mutateWorkingTreeStatus: vi.fn(),
useFetchGitAheadBehind: vi.fn(),
useFetchGitInfo: vi.fn(),
useFetchGitWorkingTreeStatus: vi.fn(),
}));
vi.mock('../BranchSwitcher', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('@/store/device', () => ({
useFetchGitAheadBehind: gitHookMocks.useFetchGitAheadBehind,
useFetchGitInfo: gitHookMocks.useFetchGitInfo,
useFetchGitWorkingTreeStatus: gitHookMocks.useFetchGitWorkingTreeStatus,
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: typeof globalStoreMock) => unknown) =>
selector(globalStoreMock),
}));
vi.mock('@/store/global/selectors', () => ({
systemStatusSelectors: {
showRightPanel: (state: typeof globalStoreMock) => state.status.showRightPanel,
},
}));
vi.mock('@/services/electron/system', () => ({
electronSystemService: { openExternalLink: vi.fn() },
}));
vi.mock('@/services/git', () => ({
gitService: {
pullGitBranch: vi.fn(),
pushGitBranch: vi.fn(),
},
}));
vi.mock('@/components/AntdStaticMethods', () => ({
message: { error: vi.fn(), info: vi.fn(), success: vi.fn() },
}));
vi.mock('@/components/RingLoading', () => ({
default: () => <span data-testid="ring-loading" />,
}));
vi.mock('@lobehub/ui', () => ({
Icon: () => <span data-testid="icon" />,
Tooltip: ({ children, title }: { children: ReactNode; title?: ReactNode }) => (
<div data-title={typeof title === 'string' ? title : undefined}>{children}</div>
),
}));
vi.mock('antd-style', () => ({
createStaticStyles: () => ({}),
cssVar: new Proxy({}, { get: () => 'var(--mock)' }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${JSON.stringify(values)}` : key,
}),
}));
beforeEach(() => {
vi.clearAllMocks();
globalStoreMock.status.showRightPanel = false;
globalStoreMock.status.workingSidebarTab = 'resources';
gitHookMocks.useFetchGitInfo.mockReturnValue({
data: { branch: 'fix/remote-review', detached: false, pullRequest: null },
mutate: gitHookMocks.mutateGitInfo,
});
gitHookMocks.useFetchGitWorkingTreeStatus.mockReturnValue({
data: { added: 1, clean: false, deleted: 0, modified: 2, total: 3 },
mutate: gitHookMocks.mutateWorkingTreeStatus,
});
gitHookMocks.useFetchGitAheadBehind.mockReturnValue({
data: undefined,
mutate: gitHookMocks.mutateAheadBehind,
});
});
describe('GitStatus', () => {
it('opens the review panel when clicking remote device diff stats', () => {
render(<GitStatus deviceId="device-1" isGithub={false} path="/repo" />);
fireEvent.click(screen.getByRole('button'));
expect(globalStoreMock.setWorkingSidebarTab).toHaveBeenCalledWith('review');
expect(globalStoreMock.toggleRightPanel).toHaveBeenCalledWith(true);
});
});