Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion src/store/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ const clampHistorySummary = (
typeof value === 'string'
? value.slice(0, MAX_CHAT_HISTORY_SUMMARY_LENGTH)
: undefined;
const CLOUD_KEY_PENDING_RETRY_DELAYS_MS = [1000, 1500, 2500, 5000];
const BILLING_BUDGET_SYNC_PENDING_PATTERN =
/billing budget sync is still pending|budget sync.*pending/i;

type ConfirmedUserPromptSources = {
lastMessageContent?: unknown;
Expand Down Expand Up @@ -109,6 +112,78 @@ const hasApiCode = (value: unknown, code: string) =>
value !== null &&
String((value as { code?: unknown }).code) === code;

const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));

function getErrorText(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.map(getErrorText).filter(Boolean).join(' ');
}
if (typeof value !== 'object' || value === null) {
return '';
}

const record = value as Record<string, any>;
return [
record.text,
record.message,
record.error,
record.detail,
record.response?.data?.text,
record.response?.data?.message,
record.response?.data?.detail,
]
.map(getErrorText)
.filter(Boolean)
.join(' ');
}

function isBillingBudgetSyncPending(value: unknown): boolean {
return BILLING_BUDGET_SYNC_PENDING_PATTERN.test(getErrorText(value));
}

async function fetchCloudUserKeyWithRetry(): Promise<any> {
let lastPendingResponse: any = null;

for (
let attempt = 0;
attempt <= CLOUD_KEY_PENDING_RETRY_DELAYS_MS.length;
attempt += 1
) {
try {
const res = await proxyFetchGet('/api/v1/user/key');
if (res?.value || !isBillingBudgetSyncPending(res)) {
return res;
}
lastPendingResponse = res;
} catch (error: any) {
if (!isBillingBudgetSyncPending(error)) {
throw error;
}
lastPendingResponse = error;
if (attempt >= CLOUD_KEY_PENDING_RETRY_DELAYS_MS.length) {
throw error;
}
}

if (attempt >= CLOUD_KEY_PENDING_RETRY_DELAYS_MS.length) {
return lastPendingResponse;
}

const delayMs = CLOUD_KEY_PENDING_RETRY_DELAYS_MS[attempt];
console.info('[startTask] Cloud user key pending billing sync; retrying', {
attempt: attempt + 1,
delayMs,
});
await delay(delayMs);
}

return lastPendingResponse;
}

let _host: AppHost | null = null;

// Per-step request_usage tokens keyed by `${taskId}:${agentId}`; needed
Expand Down Expand Up @@ -1656,7 +1731,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>

let res: any;
try {
res = await proxyFetchGet('/api/v1/user/key');
res = await fetchCloudUserKeyWithRetry();
} catch (error: any) {
finishStartupFailure();
const responseData = error?.response?.data;
Expand Down
98 changes: 98 additions & 0 deletions test/unit/store/chatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,104 @@ describe('ChatStore - Core Functionality', () => {
});
});

describe('Cloud key fetching', () => {
it('retries the cloud user key while billing budget sync is pending', async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useChatStore());
const appendInitChatStore = vi.fn(
(_projectId: string, customTaskId?: string) => {
const optimisticTaskId = result.current
.getState()
.create(customTaskId || 'cloud-key-task');
result.current.getState().setActiveTaskId(optimisticTaskId);
return {
taskId: optimisticTaskId,
chatStore: result.current,
};
}
);
const setHistoryId = vi.fn();
const getProjectStoreState = vi.mocked(useProjectStore.getState);
const previousProjectStoreImplementation =
getProjectStoreState.getMockImplementation();

try {
getProjectStoreState.mockReturnValue({
activeProjectId: 'project-1',
appendInitChatStore,
getProjectById: () => ({
id: 'project-1',
mode: 'single-agent',
}),
getProjectModel: () => null,
setProjectModel: vi.fn(),
getHistoryId: () => null,
getAllChatStores: () => [{ chatStore: result.current }],
setHistoryId,
} as any);

let userKeyCalls = 0;
vi.mocked(proxyFetchGet).mockImplementation((url: string) => {
if (url === '/api/v1/user/key') {
userKeyCalls += 1;
if (userKeyCalls < 3) {
return Promise.resolve({
value: '',
text: 'Billing budget sync is still pending. Please retry in a moment.',
});
}
return Promise.resolve({
value: 'cloud-key',
api_url: 'https://cloud.example',
});
}
if (url === '/api/v1/remote-sub-agent-providers') {
return Promise.resolve({ items: [] });
}
return Promise.resolve({ items: [] });
});

const initialTaskId = result.current
.getState()
.create('initial-cloud-key-task');
let startPromise!: Promise<void>;
act(() => {
startPromise = result.current
.getState()
.startTask(
initialTaskId,
undefined,
undefined,
undefined,
'Start from remote control',
[],
undefined,
'project-1',
'single-agent' as any
);
});

await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
await startPromise;
});

expect(userKeyCalls).toBe(3);
expect(fetchEventSource).toHaveBeenCalled();
expect(
String(vi.mocked(fetchEventSource).mock.calls[0]?.[1]?.body)
).toContain('"api_key":"cloud-key"');
} finally {
vi.useRealTimers();
if (previousProjectStoreImplementation) {
getProjectStoreState.mockImplementation(
previousProjectStoreImplementation
);
}
}
});
});

describe('Cross-store task safety', () => {
it('does not create phantom tasks through task-scoped setters', () => {
const { result } = renderHook(() => useChatStore());
Expand Down
Loading