mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +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', () => {
|
it('deduplicates the following turn.failed after a Codex JSONL error event', () => {
|
||||||
const adapter = new CodexAdapter();
|
const adapter = new CodexAdapter();
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ const CODEX_USER_RATE_LIMIT_PATTERNS = [
|
|||||||
/\busage limit\b/i,
|
/\busage limit\b/i,
|
||||||
] as const;
|
] 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 {
|
interface CodexBaseItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -92,6 +93,15 @@ type CodexToolItem =
|
|||||||
| CodexMcpToolCallItem
|
| CodexMcpToolCallItem
|
||||||
| CodexTodoListItem;
|
| CodexTodoListItem;
|
||||||
|
|
||||||
|
interface ZonedDateTimeParts {
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
month: number;
|
||||||
|
second: number;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
const isCommandExecutionItem = (item: CodexToolItem): item is CodexCommandExecutionItem =>
|
const isCommandExecutionItem = (item: CodexToolItem): item is CodexCommandExecutionItem =>
|
||||||
item.type === CODEX_COMMAND_API;
|
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 parseCodexRetryAt = (message: string, now = new Date()): number | undefined => {
|
||||||
const match = CODEX_RETRY_AT_PATTERN.exec(message);
|
const match = CODEX_RETRY_AT_PATTERN.exec(message);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
const hour = Number(match[1]);
|
const [, rawHour, rawMinute, rawAdjacentMeridiem, rawSpacedMeridiem, rawTimeZone] = match;
|
||||||
const minute = match[2] ? Number(match[2]) : 0;
|
const hour = Number(rawHour);
|
||||||
const meridiem = match[3]?.toUpperCase();
|
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) {
|
if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||||
return;
|
return;
|
||||||
@@ -525,6 +677,10 @@ const parseCodexRetryAt = (message: string, now = new Date()): number | undefine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (timeZone) {
|
||||||
|
return parseCodexRetryAtInTimeZone(normalizedHour, minute, timeZone, now);
|
||||||
|
}
|
||||||
|
|
||||||
const resetAt = new Date(now);
|
const resetAt = new Date(now);
|
||||||
resetAt.setHours(normalizedHour, minute, 0, 0);
|
resetAt.setHours(normalizedHour, minute, 0, 0);
|
||||||
if (resetAt.getTime() <= now.getTime()) {
|
if (resetAt.getTime() <= now.getTime()) {
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ interface GitStatusProps {
|
|||||||
|
|
||||||
const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
|
const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
|
||||||
const { t } = useTranslation('device');
|
const { t } = useTranslation('device');
|
||||||
const local = !deviceId;
|
|
||||||
// Transport (Electron IPC vs device RPC) is decided inside the service; the
|
// Transport (Electron IPC vs device RPC) is decided inside the service; the
|
||||||
// component just reads, identically for local and remote.
|
// component just reads, identically for local and remote.
|
||||||
const { data, mutate } = useFetchGitInfo(deviceId, path, isGithub);
|
const { data, mutate } = useFetchGitInfo(deviceId, path, isGithub);
|
||||||
@@ -365,11 +364,7 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub, deviceId }) => {
|
|||||||
const diffNode = (() => {
|
const diffNode = (() => {
|
||||||
if (!hasChanges || !workingStatus) return null;
|
if (!hasChanges || !workingStatus) return null;
|
||||||
const diffButton = (
|
const diffButton = (
|
||||||
<div
|
<div className={styles.trigger} role="button" onClick={handleToggleReview}>
|
||||||
className={styles.trigger}
|
|
||||||
role={local ? 'button' : undefined}
|
|
||||||
onClick={local ? handleToggleReview : undefined}
|
|
||||||
>
|
|
||||||
<span className={styles.diffStat}>
|
<span className={styles.diffStat}>
|
||||||
{workingStatus.added > 0 && (
|
{workingStatus.added > 0 && (
|
||||||
<span className={styles.diffStatAdded}>+{workingStatus.added}</span>
|
<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