Skip to content

Commit 5473c84

Browse files
committed
fix(web): validate onboarding runtime on continue
1 parent 8d62fda commit 5473c84

2 files changed

Lines changed: 139 additions & 71 deletions

File tree

apps/web/src/components/EntryShell.tsx

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2250,13 +2250,14 @@ function OnboardingView({
22502250
: { status: 'idle' as const };
22512251
const canTestAgent = Boolean(selectedAgent) && daemonLive;
22522252
const runtimeSetupStep = step === 2;
2253-
const byokConnectionVerified =
2254-
visibleProviderTestState.status === 'done' && visibleProviderTestState.result.ok;
2255-
const localConnectionVerified =
2256-
visibleAgentTestState.status === 'done' && visibleAgentTestState.result.ok;
2253+
const localRuntimeConfigured = selectedAgent?.available === true;
2254+
const byokRuntimeConfigured = canTestProvider;
22572255
const connectStepRuntimeReady =
2258-
(runtime === 'local' && selectedAgent !== null && localConnectionVerified) ||
2259-
(runtime === 'byok' && byokConnectionVerified);
2256+
(runtime === 'local' && localRuntimeConfigured) ||
2257+
(runtime === 'byok' && byokRuntimeConfigured);
2258+
const connectStepTestRunning =
2259+
(runtime === 'local' && visibleAgentTestState.status === 'running') ||
2260+
(runtime === 'byok' && visibleProviderTestState.status === 'running');
22602261
const connectStepBlocked = runtimeSetupStep && !connectStepRuntimeReady;
22612262
const connectGateReason: 'no_runtime' | 'local_agent_unavailable' | 'byok_unverified' | null =
22622263
!runtimeSetupStep
@@ -2725,8 +2726,13 @@ function OnboardingView({
27252726
setStep(2);
27262727
}
27272728
async function handlePrimaryAction() {
2728-
if (connectStepBlocked) return;
2729+
if (connectStepBlocked || connectStepTestRunning) return;
27292730
if (runtime === 'local' && selectedAgent) {
2731+
const testResult =
2732+
visibleAgentTestState.status === 'done' && visibleAgentTestState.result.ok
2733+
? visibleAgentTestState.result
2734+
: await testAgentInline();
2735+
if (!testResult?.ok) return;
27302736
await onConfigPersist({
27312737
...config,
27322738
mode: 'daemon',
@@ -2737,6 +2743,11 @@ function OnboardingView({
27372743
return;
27382744
}
27392745
if (runtime === 'byok') {
2746+
const testResult =
2747+
visibleProviderTestState.status === 'done' && visibleProviderTestState.result.ok
2748+
? visibleProviderTestState.result
2749+
: await testProviderInline();
2750+
if (!testResult?.ok) return;
27402751
await onConfigPersist({ ...config, mode: 'api' });
27412752
emitOnboardingClick('continue', 'continue', { runtime_type: 'byok' });
27422753
completeStreamlinedOnboarding('byok');
@@ -3076,8 +3087,8 @@ function OnboardingView({
30763087
}
30773088
}
30783089

3079-
async function testProviderInline() {
3080-
if (!canTestProvider || providerTestState.status === 'running') return;
3090+
async function testProviderInline(): Promise<ConnectionTestResponse | null> {
3091+
if (!canTestProvider || providerTestState.status === 'running') return null;
30813092
const inputKey = providerTestInputKey;
30823093
providerAutoTestKeyRef.current = inputKey;
30833094
setProviderTestState({ status: 'running', inputKey });
@@ -3093,23 +3104,22 @@ function OnboardingView({
30933104
: undefined,
30943105
});
30953106
setProviderTestState({ status: 'done', inputKey, result });
3107+
return result;
30963108
} catch (error) {
3097-
setProviderTestState({
3098-
status: 'done',
3099-
inputKey,
3100-
result: {
3101-
ok: false,
3102-
kind: 'unknown',
3103-
latencyMs: 0,
3104-
model: config.model,
3105-
detail: error instanceof Error ? error.message : 'Test request failed',
3106-
},
3107-
});
3109+
const result: ConnectionTestResponse = {
3110+
ok: false,
3111+
kind: 'unknown',
3112+
latencyMs: 0,
3113+
model: config.model,
3114+
detail: error instanceof Error ? error.message : 'Test request failed',
3115+
};
3116+
setProviderTestState({ status: 'done', inputKey, result });
3117+
return result;
31083118
}
31093119
}
31103120

3111-
async function testAgentInline() {
3112-
if (!selectedAgent || !canTestAgent || agentTestState.status === 'running') return;
3121+
async function testAgentInline(): Promise<ConnectionTestResponse | null> {
3122+
if (!selectedAgent || !canTestAgent || agentTestState.status === 'running') return null;
31133123
const inputKey = agentTestInputKey;
31143124
const agent = selectedAgent;
31153125
const model = selectedAgentTestModel;
@@ -3123,19 +3133,18 @@ function OnboardingView({
31233133
agentCliEnv: config.agentCliEnv ?? {},
31243134
});
31253135
setAgentTestState({ status: 'done', inputKey, result });
3136+
return result;
31263137
} catch (error) {
3127-
setAgentTestState({
3128-
status: 'done',
3129-
inputKey,
3130-
result: {
3131-
ok: false,
3132-
kind: 'unknown',
3133-
latencyMs: 0,
3134-
model: model || 'default',
3135-
agentName: agent.name,
3136-
detail: error instanceof Error ? error.message : 'Test request failed',
3137-
},
3138-
});
3138+
const result: ConnectionTestResponse = {
3139+
ok: false,
3140+
kind: 'unknown',
3141+
latencyMs: 0,
3142+
model: model || 'default',
3143+
agentName: agent.name,
3144+
detail: error instanceof Error ? error.message : 'Test request failed',
3145+
};
3146+
setAgentTestState({ status: 'done', inputKey, result });
3147+
return result;
31393148
}
31403149
}
31413150

@@ -3620,7 +3629,7 @@ function OnboardingView({
36203629
type="button"
36213630
className={`onboarding-view__primary${connectGateTooltip ? ' od-tooltip' : ''}`}
36223631
onClick={handlePrimaryAction}
3623-
disabled={amrLoginPending || amrLoginCancelPending}
3632+
disabled={amrLoginPending || amrLoginCancelPending || connectStepTestRunning}
36243633
aria-disabled={connectStepBlocked || undefined}
36253634
data-tooltip={connectGateTooltip ?? undefined}
36263635
data-tooltip-placement="top"

apps/web/tests/components/EntryShell.onboarding.test.tsx

Lines changed: 95 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -873,8 +873,9 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
873873
expect(props.onAgentChange).not.toHaveBeenCalled();
874874
});
875875

876-
it('requires a successful Local Agent test before persisting and completing setup', async () => {
877-
globalThis.fetch = vi.fn(async (input, init) => {
876+
it('tests Local Agent on Continue, stays on failure, and retries on the next click', async () => {
877+
let testCalls = 0;
878+
const fetchMock = vi.fn(async (input, init) => {
878879
const url = String(input);
879880
if (url.endsWith('/api/integrations/vela/status')) {
880881
return jsonResponse({
@@ -885,64 +886,56 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
885886
});
886887
}
887888
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
888-
return jsonResponse({
889-
ok: true,
890-
kind: 'success',
891-
latencyMs: 12,
892-
model: 'sonnet',
893-
sample: 'pong',
894-
agentName: 'Claude Code',
895-
});
889+
testCalls += 1;
890+
return testCalls === 1
891+
? jsonResponse({
892+
ok: false,
893+
kind: 'agent_spawn_failed',
894+
latencyMs: 12,
895+
model: 'sonnet',
896+
agentName: 'Claude Code',
897+
detail: 'process exited before responding',
898+
})
899+
: jsonResponse({
900+
ok: true,
901+
kind: 'success',
902+
latencyMs: 12,
903+
model: 'sonnet',
904+
sample: 'pong',
905+
agentName: 'Claude Code',
906+
});
896907
}
897908
throw new Error(`unexpected fetch: ${url}`);
898-
}) as typeof fetch;
909+
});
910+
globalThis.fetch = fetchMock as typeof fetch;
899911
const props = renderOnboarding({
900912
config: baseConfig({
901913
agentId: 'claude-code',
902914
agentModels: { 'claude-code': { model: 'sonnet' } },
903915
}),
904916
});
905917

906-
fireEvent.click(
907-
await screen.findByRole('button', { name: /Continue \(signed in\)/i }),
908-
);
909-
fireEvent.click(await screen.findByRole('radio', { name: /Local Agent/i }));
910-
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
911-
912-
expect(await screen.findByRole('heading', { name: 'Local Agent' })).toBeTruthy();
918+
await openLocalRuntimeSetup();
913919
const continueButton = screen.getByRole('button', { name: /^Continue$/i });
914-
expect(continueButton.getAttribute('aria-disabled')).toBe('true');
915-
fireEvent.click(screen.getByRole('button', { name: /^Test$/i }));
916-
expect(await screen.findByText(/Claude Code replied in 12 ms/i)).toBeTruthy();
917920
expect(continueButton.getAttribute('aria-disabled')).toBeNull();
921+
918922
fireEvent.click(continueButton);
923+
expect(await screen.findByText(/Could not start Claude Code/i)).toBeTruthy();
924+
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
919925

926+
fireEvent.click(continueButton);
920927
await waitFor(() => {
928+
expect(testCalls).toBe(2);
921929
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
922930
});
923931
expect(props.onConfigPersist).toHaveBeenCalledWith(
924932
expect.objectContaining({ mode: 'daemon', agentId: 'claude-code' }),
925933
);
926-
expect(
927-
findTrackedEvent<Record<string, unknown>>(
928-
'ui_click',
929-
(payload) => payload.element === 'local_coding_agent',
930-
),
931-
).toMatchObject({
932-
area: 'model_source',
933-
step_name: 'model_source',
934-
runtime_type: 'local_cli',
935-
});
936934
expect(latestTrackedEvent('onboarding_complete_result')).toMatchObject({
937935
result: 'completed',
938936
exit_step_name: 'runtime_setup',
939937
runtime_type: 'local_cli',
940938
});
941-
expect(
942-
trackedEvents('page_view').filter(([, payload]) =>
943-
(payload as Record<string, unknown>).area === 'runtime_setup',
944-
),
945-
).toHaveLength(1);
946939
});
947940

948941
it('does not auto-select OpenDesign AMR when the AMR runtime is unavailable', async () => {
@@ -1577,6 +1570,72 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
15771570
expect(props.onApiModelChange).not.toHaveBeenCalledWith('upstream-first');
15781571
});
15791572

1573+
it('tests BYOK on Continue, stays on rate limit, and retries on the next click', async () => {
1574+
let testCalls = 0;
1575+
globalThis.fetch = vi.fn(async (input, init) => {
1576+
const url = String(input);
1577+
if (url.endsWith('/api/integrations/vela/status')) {
1578+
return jsonResponse({
1579+
loggedIn: true,
1580+
profile: 'prod',
1581+
configPath: '/x',
1582+
user: { id: 'u', email: 'user@example.com' },
1583+
});
1584+
}
1585+
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
1586+
return jsonResponse({
1587+
ok: true,
1588+
kind: 'success',
1589+
latencyMs: 10,
1590+
models: [{ id: 'gpt-test', label: 'GPT Test' }],
1591+
});
1592+
}
1593+
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
1594+
testCalls += 1;
1595+
return testCalls === 1
1596+
? jsonResponse({
1597+
ok: false,
1598+
kind: 'rate_limited',
1599+
latencyMs: 12,
1600+
model: 'gpt-test',
1601+
status: 429,
1602+
})
1603+
: jsonResponse({
1604+
ok: true,
1605+
kind: 'success',
1606+
latencyMs: 12,
1607+
model: 'gpt-test',
1608+
sample: 'Connected',
1609+
});
1610+
}
1611+
throw new Error(`unexpected fetch: ${url}`);
1612+
}) as typeof fetch;
1613+
const props = renderOnboarding({
1614+
config: baseConfig({
1615+
mode: 'api',
1616+
apiProtocol: 'openai',
1617+
apiKey: 'test-api-key',
1618+
baseUrl: 'https://api.openai.com/v1',
1619+
model: 'gpt-test',
1620+
apiProviderBaseUrl: 'https://api.openai.com/v1',
1621+
}),
1622+
});
1623+
1624+
await openByokRuntimeSetup();
1625+
const continueButton = screen.getByRole('button', { name: /^Continue$/i });
1626+
expect(continueButton.getAttribute('aria-disabled')).toBeNull();
1627+
1628+
fireEvent.click(continueButton);
1629+
expect(await screen.findByText(/rate-limited the test/i)).toBeTruthy();
1630+
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
1631+
1632+
fireEvent.click(continueButton);
1633+
await waitFor(() => {
1634+
expect(testCalls).toBe(2);
1635+
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
1636+
});
1637+
});
1638+
15801639
it('persists the BYOK config before finishing onboarding', async () => {
15811640
globalThis.fetch = vi.fn(async (input, init) => {
15821641
const url = String(input);

0 commit comments

Comments
 (0)