feat(cursor): harden live probe and runtime contracts

This commit is contained in:
Tam Nhu Tran committed 2026-04-12 01:51:26 -04:00
1 parent 2eaf760d20
commit e1049e38d4
23 files changed
+2186 -306

No files matched your search

+66
View File
@@ -57,6 +57,35 @@ interface CursorAuthResult {
message: string;
}
export interface CursorProbeResult {
ok: boolean;
stage: 'config' | 'auth' | 'daemon' | 'runtime';
status: number;
duration_ms: number;
model?: string;
error_type?: string | null;
message: string;
}
function isCursorProbeResult(value: unknown): value is CursorProbeResult {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<CursorProbeResult>;
return (
typeof candidate.message === 'string' &&
typeof candidate.stage === 'string' &&
typeof candidate.status === 'number' &&
typeof candidate.duration_ms === 'number' &&
typeof candidate.ok === 'boolean'
);
}
function getProbeErrorMessage(value: unknown): string | null {
if (!value || typeof value !== 'object' || !('error' in value)) return null;
const candidate = value as { error?: unknown };
return typeof candidate.error === 'string' ? candidate.error : null;
}
async function fetchCursorStatus(): Promise<CursorStatus> {
const res = await fetch(withApiBase('/cursor/status'));
if (!res.ok) throw new Error('Failed to fetch cursor status');
@@ -144,6 +173,24 @@ async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }>
return res.json();
}
async function probeCursorRuntime(): Promise<CursorProbeResult> {
const res = await fetch(withApiBase('/cursor/probe'), { method: 'POST' });
const payload = await res.json().catch(() => null);
if (isCursorProbeResult(payload)) {
return payload;
}
return {
ok: false,
stage: 'runtime',
status: res.status,
duration_ms: 0,
error_type: 'runtime_error',
message: getProbeErrorMessage(payload) ?? 'Failed to run live probe',
};
}
export function useCursor() {
const queryClient = useQueryClient();
@@ -205,6 +252,14 @@ export function useCursor() {
onSuccess: invalidateCursorQueries,
});
const probeMutation = useMutation({
mutationFn: probeCursorRuntime,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cursor-status'] });
queryClient.invalidateQueries({ queryKey: ['cursor-models'] });
},
});
return useMemo(
() => ({
status: statusQuery.data,
@@ -248,6 +303,12 @@ export function useCursor() {
stopDaemon: stopDaemonMutation.mutate,
stopDaemonAsync: stopDaemonMutation.mutateAsync,
isStoppingDaemon: stopDaemonMutation.isPending,
runProbe: probeMutation.mutate,
runProbeAsync: probeMutation.mutateAsync,
isRunningProbe: probeMutation.isPending,
probeResult: probeMutation.data,
resetProbe: probeMutation.reset,
}),
[
statusQuery.data,
@@ -281,6 +342,11 @@ export function useCursor() {
stopDaemonMutation.mutate,
stopDaemonMutation.mutateAsync,
stopDaemonMutation.isPending,
probeMutation.mutate,
probeMutation.mutateAsync,
probeMutation.isPending,
probeMutation.data,
probeMutation.reset,
]
);
}
+60
View File
@@ -1325,6 +1325,21 @@ const resources = {
rawChanged: 'Raw settings changed externally. Refresh and retry.',
failedSaveRaw: 'Failed to save raw settings',
savedAll: 'Cursor configuration saved',
liveProbe: 'Live Probe',
runLiveProbe: 'Run Live Probe',
rerunLiveProbe: 'Re-run Live Probe',
probing: 'Running probe...',
probeNotRun: 'Probe not run yet',
probeLocalReadinessHint:
'Local readiness checks can pass while live runtime access still fails. Use the live probe to verify the full Cursor path.',
probeStage: 'Stage',
probeHttpStatus: 'HTTP Status',
probeDuration: 'Duration',
probeModel: 'Model',
probeMessage: 'Message',
probeSucceeded: 'Probe succeeded',
probeFailed: 'Probe failed',
probeSaveFirst: 'Save or discard Cursor changes before running the live probe',
},
droidPage: {
loadingDiagnostics: 'Loading Droid diagnostics...',
@@ -2658,6 +2673,21 @@ const resources = {
rawChanged: '原始设置已被外部修改,请刷新后重试。',
failedSaveRaw: '保存原始设置失败',
savedAll: 'Cursor 配置已保存',
liveProbe: '实时探测',
runLiveProbe: '运行实时探测',
rerunLiveProbe: '重新运行实时探测',
probing: '正在运行探测...',
probeNotRun: '尚未运行探测',
probeLocalReadinessHint:
'本地就绪检查可能通过,但实时运行链路仍可能失败。请使用实时探测验证完整的 Cursor 路径。',
probeStage: '阶段',
probeHttpStatus: 'HTTP 状态',
probeDuration: '耗时',
probeModel: '模型',
probeMessage: '消息',
probeSucceeded: '探测成功',
probeFailed: '探测失败',
probeSaveFirst: '请先保存或放弃 Cursor 更改,再运行实时探测',
},
droidPage: {
loadingDiagnostics: '加载 Droid 诊断中...',
@@ -4085,6 +4115,21 @@ const resources = {
rawChanged: 'Cài đặt thô đã thay đổi bên ngoài. Làm mới và thử lại.',
failedSaveRaw: 'Không lưu được cài đặt thô',
savedAll: 'Đã lưu cấu hình Cursor',
liveProbe: 'Kiểm tra trực tiếp',
runLiveProbe: 'Chạy kiểm tra trực tiếp',
rerunLiveProbe: 'Chạy lại kiểm tra trực tiếp',
probing: 'Đang kiểm tra...',
probeNotRun: 'Chưa chạy kiểm tra',
probeLocalReadinessHint:
'Các kiểm tra sẵn sàng cục bộ có thể đạt, nhưng luồng chạy thực tế vẫn có thể lỗi. Hãy dùng kiểm tra trực tiếp để xác minh toàn bộ đường đi của Cursor.',
probeStage: 'Giai đoạn',
probeHttpStatus: 'Trạng thái HTTP',
probeDuration: 'Thời lượng',
probeModel: 'Mô hình',
probeMessage: 'Thông điệp',
probeSucceeded: 'Kiểm tra thành công',
probeFailed: 'Kiểm tra thất bại',
probeSaveFirst: 'Hãy lưu hoặc bỏ các thay đổi Cursor trước khi chạy kiểm tra trực tiếp',
},
droidPage: {
loadingDiagnostics: 'Đang tải chẩn đoán Droid...',
@@ -5520,6 +5565,21 @@ const resources = {
rawChanged: '生の設定が外部で変更されました。更新して再試行してください。',
failedSaveRaw: '生の設定の保存に失敗しました',
savedAll: 'Cursor設定を保存しました',
liveProbe: 'ライブプローブ',
runLiveProbe: 'ライブプローブを実行',
rerunLiveProbe: 'ライブプローブを再実行',
probing: 'プローブを実行中...',
probeNotRun: 'まだプローブを実行していません',
probeLocalReadinessHint:
'ローカルの準備チェックが通っても、実際のランタイム経路は失敗する場合があります。ライブプローブで Cursor の完全な経路を確認してください。',
probeStage: '段階',
probeHttpStatus: 'HTTP ステータス',
probeDuration: '所要時間',
probeModel: 'モデル',
probeMessage: 'メッセージ',
probeSucceeded: 'プローブ成功',
probeFailed: 'プローブ失敗',
probeSaveFirst: 'ライブプローブを実行する前に Cursor の変更を保存するか破棄してください',
},
droidPage: {
loadingDiagnostics: 'Droid診断を読み込み中...',
+159
View File
@@ -62,6 +62,32 @@ interface RawSettingsParseResult {
error?: string;
}
function buildProbeSnapshotKey(
status?: {
enabled?: boolean;
authenticated?: boolean;
token_expired?: boolean;
daemon_running?: boolean;
port?: number;
ghost_mode?: boolean;
},
config?: {
model?: string;
auto_start?: boolean;
}
): string {
return JSON.stringify({
enabled: status?.enabled ?? null,
authenticated: status?.authenticated ?? null,
token_expired: status?.token_expired ?? null,
daemon_running: status?.daemon_running ?? null,
port: status?.port ?? null,
ghost_mode: status?.ghost_mode ?? null,
auto_start: config?.auto_start ?? null,
model: config?.model ?? null,
});
}
function buildConfigDraft(config?: {
port?: number;
auto_start?: boolean;
@@ -284,6 +310,10 @@ export function CursorPage() {
isStartingDaemon,
stopDaemonAsync,
isStoppingDaemon,
runProbeAsync,
isRunningProbe,
probeResult,
resetProbe,
} = useCursor();
const [configDraft, setConfigDraft] = useState<CursorConfigDraft>(() => buildConfigDraft());
@@ -293,6 +323,9 @@ export function CursorPage() {
const [manualAuthOpen, setManualAuthOpen] = useState(false);
const [manualToken, setManualToken] = useState('');
const [manualMachineId, setManualMachineId] = useState('');
const [probeSnapshotKey, setProbeSnapshotKey] = useState<string | null>(() =>
probeResult ? buildProbeSnapshotKey(status, config) : null
);
const pristineConfigDraft = buildConfigDraft(config);
@@ -318,6 +351,14 @@ export function CursorPage() {
const isRawJsonValid = rawParseResult.isValid;
const hasChanges = configDirty || rawConfigDirty;
const canSave = !rawConfigDirty || (rawSettingsReady && isRawJsonValid);
const currentProbeSnapshotKey = buildProbeSnapshotKey(status, config);
const visibleProbeResult =
probeResult &&
!hasChanges &&
probeSnapshotKey !== null &&
probeSnapshotKey === currentProbeSnapshotKey
? probeResult
: null;
const orderedModels = useMemo(() => {
const seen = new Set<string>();
const sorted = [...models].sort((a, b) => a.name.localeCompare(b.name));
@@ -349,6 +390,11 @@ export function CursorPage() {
setConfigDirty(true);
};
const clearProbeState = () => {
resetProbe();
setProbeSnapshotKey(null);
};
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
const integrationBadge = useMemo(
() =>
@@ -395,6 +441,7 @@ export function CursorPage() {
haiku_model: effectiveHaikuModel || undefined,
})
);
clearProbeState();
if (!suppressSuccessToast) {
toast.success(t('cursorPage.savedConfig'));
}
@@ -500,6 +547,7 @@ export function CursorPage() {
const handleToggleEnabled = async (enabled: boolean) => {
try {
await updateConfigAsync({ enabled });
clearProbeState();
toast.success(
enabled ? t('cursorPage.integrationEnabled') : t('cursorPage.integrationDisabled')
);
@@ -511,6 +559,7 @@ export function CursorPage() {
const handleAutoDetectAuth = async () => {
try {
await autoDetectAuthAsync();
clearProbeState();
toast.success(t('cursorPage.credentialsImported'));
} catch (error) {
toast.error((error as Error).message || t('cursorPage.autoDetectFailed'));
@@ -528,6 +577,7 @@ export function CursorPage() {
accessToken: manualToken.trim(),
machineId: manualMachineId.trim(),
});
clearProbeState();
toast.success(t('cursorPage.credentialsImported'));
setManualAuthOpen(false);
setManualToken('');
@@ -544,6 +594,7 @@ export function CursorPage() {
toast.error(result.error || t('cursorPage.failedStartDaemon'));
return;
}
clearProbeState();
toast.success(
result.pid
? t('cursorPage.daemonStartedWithPid', { pid: result.pid })
@@ -561,12 +612,34 @@ export function CursorPage() {
toast.error(result.error || t('cursorPage.failedStopDaemon'));
return;
}
clearProbeState();
toast.success(t('cursorPage.daemonStopped'));
} catch (error) {
toast.error((error as Error).message || t('cursorPage.failedStopDaemon'));
}
};
const handleRunProbe = async () => {
if (hasChanges) {
toast.error(t('cursorPage.probeSaveFirst'));
return;
}
try {
const result = await runProbeAsync();
const refreshedStatus = await refetchStatus();
setProbeSnapshotKey(buildProbeSnapshotKey(refreshedStatus.data ?? status, config));
if (result.ok) {
toast.success(t('cursorPage.probeSucceeded'));
return;
}
toast.error(result.message || t('cursorPage.probeFailed'));
} catch (error) {
toast.error((error as Error).message || t('cursorPage.probeFailed'));
}
};
const handleSaveRawSettings = async ({
suppressSuccessToast = false,
}: { suppressSuccessToast?: boolean } = {}) => {
@@ -586,6 +659,7 @@ export function CursorPage() {
expectedMtime: rawSettings?.mtime,
});
setRawConfigDirty(false);
clearProbeState();
if (!suppressSuccessToast) {
toast.success(t('cursorPage.rawSaved'));
}
@@ -627,6 +701,7 @@ export function CursorPage() {
const handleHeaderRefresh = () => {
setRawConfigDirty(false);
clearProbeState();
refetchStatus();
refetchRawSettings();
};
@@ -710,6 +785,71 @@ export function CursorPage() {
/>
</div>
<div className="rounded-md border bg-background/80 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Code2 className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('cursorPage.liveProbe')}
</span>
</div>
<Badge
variant={probeResult ? 'outline' : 'secondary'}
className={cn(
probeResult?.ok && 'border-green-500/40 text-green-600 dark:text-green-300',
probeResult &&
!probeResult.ok &&
'border-red-500/40 text-red-600 dark:text-red-300'
)}
>
{visibleProbeResult
? visibleProbeResult.ok
? t('cursorPage.probeSucceeded')
: t('cursorPage.probeFailed')
: t('cursorPage.probeNotRun')}
</Badge>
</div>
{visibleProbeResult ? (
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t('cursorPage.probeStage')}</span>
<span className="font-mono uppercase">{visibleProbeResult.stage}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('cursorPage.probeHttpStatus')}
</span>
<span className="font-mono">{visibleProbeResult.status}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t('cursorPage.probeDuration')}</span>
<span className="font-mono">{visibleProbeResult.duration_ms} ms</span>
</div>
{visibleProbeResult.model ? (
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t('cursorPage.probeModel')}</span>
<span className="font-mono text-[11px] text-right break-all">
{visibleProbeResult.model}
</span>
</div>
) : null}
<div className="space-y-1 pt-1">
<span className="text-muted-foreground">{t('cursorPage.probeMessage')}</span>
<p className="text-[11px] leading-relaxed break-words">
{visibleProbeResult.message}
</p>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">{t('cursorPage.probeNotRun')}</p>
)}
<p className="text-[11px] text-muted-foreground">
{t('cursorPage.probeLocalReadinessHint')}
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t('cursorPage.actions')}
@@ -763,6 +903,25 @@ export function CursorPage() {
{t('cursorPage.manualAuthImport')}
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleRunProbe}
disabled={isRunningProbe}
>
{isRunningProbe ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
) : (
<Code2 className="w-3.5 h-3.5 mr-1.5" />
)}
{isRunningProbe
? t('cursorPage.probing')
: visibleProbeResult
? t('cursorPage.rerunLiveProbe')
: t('cursorPage.runLiveProbe')}
</Button>
{status?.daemon_running ? (
<Button
variant="outline"
+116
View File
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { AllProviders } from '../../setup/test-utils';
import { useCursor } from '@/hooks/use-cursor';
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
describe('useCursor', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('preserves structured probe failures and refreshes live status queries', async () => {
const probeFailure = {
ok: false,
stage: 'daemon',
status: 503,
duration_ms: 321,
error_type: 'daemon_not_running',
message: 'Cursor daemon is not running.',
};
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/cursor/status')) {
return Promise.resolve(
createJsonResponse({
enabled: true,
authenticated: true,
auth_method: 'manual',
token_age: 1,
token_expired: false,
daemon_running: false,
port: 20129,
auto_start: false,
ghost_mode: true,
})
);
}
if (url.endsWith('/api/cursor/settings')) {
return Promise.resolve(
createJsonResponse({
enabled: true,
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
})
);
}
if (url.endsWith('/api/cursor/models')) {
return Promise.resolve(
createJsonResponse({
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
current: 'gpt-5.3-codex',
})
);
}
if (url.endsWith('/api/cursor/settings/raw')) {
return Promise.resolve(
createJsonResponse({
settings: {},
mtime: 100,
path: '/tmp/cursor.settings.json',
exists: true,
})
);
}
if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') {
return Promise.resolve(createJsonResponse(probeFailure, 503));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCursor(), { wrapper });
await waitFor(() => expect(result.current.status?.enabled).toBe(true));
await waitFor(() => expect(result.current.models.length).toBe(1));
await act(async () => {
const probeResult = await result.current.runProbeAsync();
expect(probeResult).toMatchObject(probeFailure);
});
await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status'))
.length
).toBeGreaterThanOrEqual(2)
);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models'))
.length
).toBeGreaterThanOrEqual(2)
);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
const mocks = vi.hoisted(() => ({
useCursor: vi.fn(),
runProbeAsync: vi.fn(),
resetProbe: vi.fn(),
refetchStatus: vi.fn(),
refetchRawSettings: vi.fn(),
updateConfigAsync: vi.fn(),
saveRawSettingsAsync: vi.fn(),
autoDetectAuthAsync: vi.fn(),
importManualAuthAsync: vi.fn(),
startDaemonAsync: vi.fn(),
stopDaemonAsync: vi.fn(),
}));
vi.mock('@/hooks/use-cursor', () => ({
useCursor: mocks.useCursor,
}));
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}));
vi.mock('sonner', () => ({
toast: toastMocks,
}));
vi.mock('@/components/copilot/config-form/raw-editor-section', () => ({
RawEditorSection: () => <div>Raw Editor</div>,
}));
vi.mock('@/components/ui/searchable-select', () => ({
SearchableSelect: ({ value }: { value?: string }) => (
<div data-testid="searchable-select">{value ?? 'select'}</div>
),
}));
import { CursorPage } from '@/pages/cursor';
const probeFailure = {
ok: false,
stage: 'daemon',
status: 503,
duration_ms: 321,
error_type: 'daemon_not_running',
model: 'gpt-5.3-codex',
message: 'Cursor daemon is not running.',
};
function buildUseCursorResult(overrides: Record<string, unknown> = {}) {
return {
status: {
enabled: true,
authenticated: true,
auth_method: 'manual',
token_age: 1,
token_expired: false,
daemon_running: false,
port: 20129,
auto_start: false,
ghost_mode: true,
},
statusLoading: false,
refetchStatus: mocks.refetchStatus,
config: {
enabled: true,
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
opus_model: 'gpt-5.1-codex-max',
sonnet_model: 'gpt-5.3-codex',
haiku_model: 'gpt-5-mini',
},
updateConfigAsync: mocks.updateConfigAsync,
isUpdatingConfig: false,
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
modelsLoading: false,
currentModel: 'gpt-5.3-codex',
rawSettings: {
settings: {},
mtime: 100,
path: '/tmp/cursor.settings.json',
exists: true,
},
rawSettingsLoading: false,
refetchRawSettings: mocks.refetchRawSettings,
saveRawSettingsAsync: mocks.saveRawSettingsAsync,
isSavingRawSettings: false,
autoDetectAuthAsync: mocks.autoDetectAuthAsync,
isAutoDetectingAuth: false,
importManualAuthAsync: mocks.importManualAuthAsync,
isImportingManualAuth: false,
startDaemonAsync: mocks.startDaemonAsync,
isStartingDaemon: false,
stopDaemonAsync: mocks.stopDaemonAsync,
isStoppingDaemon: false,
runProbeAsync: mocks.runProbeAsync,
isRunningProbe: false,
probeResult: undefined,
resetProbe: mocks.resetProbe,
...overrides,
};
}
describe('CursorPage', () => {
beforeEach(() => {
mocks.runProbeAsync.mockReset();
mocks.resetProbe.mockReset();
mocks.runProbeAsync.mockResolvedValue(probeFailure);
mocks.useCursor.mockReturnValue(buildUseCursorResult());
toastMocks.error.mockReset();
toastMocks.success.mockReset();
});
it('renders persistent live probe results in the sidebar', async () => {
mocks.useCursor.mockReturnValue(
buildUseCursorResult({
probeResult: probeFailure,
})
);
render(<CursorPage />);
expect(screen.getByText('Live Probe')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('Probe failed')).toBeInTheDocument());
expect(screen.getByText('503')).toBeInTheDocument();
expect(screen.getByText('321 ms')).toBeInTheDocument();
expect(screen.getByText('Cursor daemon is not running.')).toBeInTheDocument();
expect(screen.getAllByText('gpt-5.3-codex').length).toBeGreaterThan(0);
});
it('runs the live probe from the sidebar action', async () => {
render(<CursorPage />);
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
await waitFor(() => expect(mocks.runProbeAsync).toHaveBeenCalledTimes(1));
});
it('blocks live probe runs while local edits are unsaved', async () => {
render(<CursorPage />);
await userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
await userEvent.clear(screen.getByLabelText('Port'));
await userEvent.type(screen.getByLabelText('Port'), '20130');
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
expect(mocks.runProbeAsync).not.toHaveBeenCalled();
expect(toastMocks.error).toHaveBeenCalledWith(
'Save or discard Cursor changes before running the live probe'
);
});
});