mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user