diff --git a/apps/staged/src-tauri/src/pikchr_mcp.rs b/apps/staged/src-tauri/src/pikchr_mcp.rs index 99115aa57..aa3ebfe71 100644 --- a/apps/staged/src-tauri/src/pikchr_mcp.rs +++ b/apps/staged/src-tauri/src/pikchr_mcp.rs @@ -1713,6 +1713,7 @@ arrow from COLL.e to SNOW.w"#; cancel_token: &CancellationToken, _agent_session_id: Option<&str>, _config_options: &[acp_client::AcpSessionConfigOptionSelection], + _auth_selection: Option<&acp_client::AcpAuthenticationSelection>, ) -> Result { assert!( self.registry.cancel(session_id), @@ -1804,6 +1805,7 @@ arrow from COLL.e to SNOW.w"#; _cancel_token: &CancellationToken, _agent_session_id: Option<&str>, _config_options: &[acp_client::AcpSessionConfigOptionSelection], + _auth_selection: Option<&acp_client::AcpAuthenticationSelection>, ) -> Result { self.ran.set(true); Ok(acp_client::AgentRunOutcome::Completed) diff --git a/apps/staged/src-tauri/src/pikchr_subsession.rs b/apps/staged/src-tauri/src/pikchr_subsession.rs index 5d58fab2a..23cde89cf 100644 --- a/apps/staged/src-tauri/src/pikchr_subsession.rs +++ b/apps/staged/src-tauri/src/pikchr_subsession.rs @@ -328,6 +328,7 @@ async fn generate_pikchr_source_inner( cancel_token, agent_session_id.as_deref(), config_options, + None, ) .await; writer_dyn.finalize().await; @@ -559,6 +560,7 @@ mod tests { _cancel_token: &CancellationToken, agent_session_id: Option<&str>, config_options: &[acp_client::AcpSessionConfigOptionSelection], + _auth_selection: Option<&acp_client::AcpAuthenticationSelection>, ) -> Result { *self.calls.lock().unwrap() += 1; self.seen_session_ids @@ -1162,6 +1164,7 @@ agent: http=false, sse=false). Select a provider that supports MCP over HTTP/SSE cancel_token: &CancellationToken, agent_session_id: Option<&str>, config_options: &[acp_client::AcpSessionConfigOptionSelection], + _auth_selection: Option<&acp_client::AcpAuthenticationSelection>, ) -> Result { self.store .update_session_status( @@ -1182,6 +1185,7 @@ agent: http=false, sse=false). Select a provider that supports MCP over HTTP/SSE cancel_token, agent_session_id, config_options, + None, ) .await } diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 1948675cf..bd87e4bae 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -1403,6 +1403,7 @@ pub fn start_session( &cancel_token, agent_session_id.as_deref(), &selected_acp_config_options, + None, ) .await { diff --git a/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts b/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts index e3551fdb4..4d15939be 100644 --- a/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts +++ b/apps/staged/src/lib/features/doctor/agentLogin.svelte.ts @@ -54,6 +54,7 @@ import { listenToEvent, type UnlistenFn } from '../../transport'; /** Output lines kept for display, oldest first. */ const MAX_OUTPUT_LINES = 40; +const LOGIN_OUTPUT_SUBSCRIPTION_ERROR = 'Could not subscribe to login output, try again'; /** How a login ended, short of failing. */ export type AgentLoginOutcome = 'completed' | 'cancelled'; @@ -300,6 +301,16 @@ function fail(attempt: Attempt, error: string) { attempt.reject(new Error(error)); } +function failRegistration(attempt: Attempt) { + if (!live(attempt)) return; + if (attempt.probing) { + finish(attempt); + attempt.reject(new Error(LOGIN_OUTPUT_SUBSCRIPTION_ERROR)); + return; + } + fail(attempt, LOGIN_OUTPUT_SUBSCRIPTION_ERROR); +} + /** A probe that found nothing — or was superseded — leaves the record alone. */ function abandon(attempt: Attempt) { finish(attempt); @@ -472,6 +483,7 @@ export function startAgentLogin(checkId: string): Promise { 'doctor-login-output', (output) => handleEvent(attempt, output), { + onRegistrationFailed: () => failRegistration(attempt), onEstablished: () => { if (!live(attempt)) return; if (started) { @@ -555,6 +567,7 @@ export function attachAgentLogin(checkId: string): Promise handleEvent(attempt, output), { + onRegistrationFailed: () => failRegistration(attempt), onEstablished: () => { if (!live(attempt)) return; if (asked) { diff --git a/apps/staged/src/lib/features/doctor/agentLogin.test.ts b/apps/staged/src/lib/features/doctor/agentLogin.test.ts index 2ebe5c21d..74b673d29 100644 --- a/apps/staged/src/lib/features/doctor/agentLogin.test.ts +++ b/apps/staged/src/lib/features/doctor/agentLogin.test.ts @@ -6,6 +6,7 @@ interface Registration { event: string; callback: (payload: DoctorLoginOutput) => void; onEstablished?: () => void; + onRegistrationFailed?: (error: unknown) => void; unlisten: ReturnType; } @@ -46,10 +47,16 @@ describe('agentLogin', () => { listenToEvent: ( event: string, callback: (payload: DoctorLoginOutput) => void, - opts?: { onEstablished?: () => void } + opts?: { onEstablished?: () => void; onRegistrationFailed?: (error: unknown) => void } ) => { const unlisten = vi.fn(); - registrations.push({ event, callback, onEstablished: opts?.onEstablished, unlisten }); + registrations.push({ + event, + callback, + onEstablished: opts?.onEstablished, + onRegistrationFailed: opts?.onRegistrationFailed, + unlisten, + }); return unlisten; }, })); @@ -277,6 +284,33 @@ describe('agentLogin', () => { expect(agentLogin.running).toBe(false); }); + it('reports listener registration failure instead of leaving a start running forever', async () => { + const { agentLogin, startAgentLogin } = await load(); + + const settled = startAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onRegistrationFailed?.(new Error('ipc down')); + + await expect(settled).rejects.toThrow('Could not subscribe to login output, try again'); + expect(startDoctorLogin).not.toHaveBeenCalled(); + expect(agentLogin.running).toBe(false); + expect(agentLogin.error).toBe('Could not subscribe to login output, try again'); + expect(registration.unlisten).toHaveBeenCalledTimes(1); + }); + + it('reports listener registration failure instead of leaving an attach probe pending', async () => { + const { agentLogin, attachAgentLogin } = await load(); + + const attached = attachAgentLogin('ai-agent-claude'); + const registration = only(); + registration.onRegistrationFailed?.(new Error('ipc down')); + + await expect(attached).rejects.toThrow('Could not subscribe to login output, try again'); + expect(doctorLoginStatus).not.toHaveBeenCalled(); + expect(agentLogin.running).toBe(false); + expect(registration.unlisten).toHaveBeenCalledTimes(1); + }); + it('keeps the code box open after a send, so a rejected code can be retried', async () => { const { agentLogin, startAgentLogin, submitAgentLoginCode } = await load(); diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index 742def8ec..6612226f4 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -81,10 +81,9 @@ } from '../../api/commands'; import { listenToEvent, type UnlistenFn } from '../../transport'; import { openSettings } from '../layout/navigation.svelte'; - import { doctorState, runChecks } from '../doctor/doctor.svelte'; - import { agentLogin, attachAgentLogin, startAgentLogin } from '../doctor/agentLogin.svelte'; import AgentLoginPrompt from '../doctor/AgentLoginPrompt.svelte'; - import { canOfferLogin, doctorCheckForProvider, isAuthenticationError } from './authRecovery'; + import { isAuthenticationError } from './authRecovery'; + import { createSessionLoginController } from './sessionLogin.svelte'; import AcpFixedConfigPicker from '../agents/AcpFixedConfigPicker.svelte'; import { agentState } from '../agents/agent.svelte'; import { @@ -244,64 +243,10 @@ * `noteTaskStopOutcome`. */ let taskStopNotices = $state>(new Map()); - /** Doctor check id for this session's agent — the login's identity. */ - let loginCheckId = $derived(session?.provider ? `ai-agent-${session.provider}` : null); - let loginCheck = $derived(doctorCheckForProvider(session?.provider, doctorState.report)); - let canLogin = $derived(canOfferLogin(loginCheck)); - let loginRunning = $derived(agentLogin.running && agentLogin.checkId === loginCheckId); - /** - * Sessions whose authentication failure has already asked for a report, so a - * scan that fails (leaving `report` null) isn't retried on every flush. - */ - let authReportRequestedFor: string | null = null; - /** - * `Log in` is the primary action on an authentication failure, but it depends - * on doctor's auth probe — and `doctorState.report` is otherwise filled in - * only by opening the Doctor settings panel. On a fresh launch that left - * every auth-failed session showing `Fix` alone until the user had visited - * that panel and come back, so run the checks the first time such a failure - * is displayed. - */ - $effect(() => { - const id = sessionId; - const failed = session?.status === 'error' || session?.status === 'cancelled'; - if (!active || !id || !failed || !isAuthenticationError(session?.errorMessage)) return; - if (doctorState.report || doctorState.loading || authReportRequestedFor === id) return; - authReportRequestedFor = id; - void runChecks(); - }); - /** - * Sessions whose authentication failure has already asked the backend about a - * running login, per open — see below. - */ - let loginAttachRequestedFor: string | null = null; - /** - * A login for this agent may already be running on the backend — started from - * the Doctor panel, from another client, or before this webview reloaded — with - * the shared record here knowing nothing of it. Ask once per open when the - * alert shows, so its URL and code box come back instead of a `Log in` the - * backend would answer "already running". Not gated on `canLogin`: that needs - * the doctor report, and the login exists whether or not it has arrived. - */ - $effect(() => { - const id = sessionId; - const checkId = loginCheckId; - if (!active) { - loginAttachRequestedFor = null; - return; - } - const failed = session?.status === 'error' || session?.status === 'cancelled'; - if (!id || !checkId || !failed || !isAuthenticationError(session?.errorMessage)) return; - if (agentLogin.running || loginAttachRequestedFor === id) return; - loginAttachRequestedFor = id; - void attachAgentLogin(checkId) - .then((outcome) => { - // A signed-in agent changes the check the "Log in" button depends on. - if (outcome === 'completed') void runChecks(); - }) - .catch(() => { - // The failure is on the shared login record, which the alert renders. - }); + const login = createSessionLoginController({ + getActive: () => active, + getSessionId: () => sessionId, + getSession: () => session, }); let inputText = $state(''); @@ -678,9 +623,6 @@ stopPolling(); unlistenStatus?.(); unlistenBackgroundHold?.(); - // A login in flight is deliberately not torn down here: the subprocess - // outlives this pane, and its shared record is what the Doctor panel — or - // this pane on its next open — needs to keep feeding it a code. }); // This pane can be mounted once and reused across opens (the `active` prop toggles @@ -843,18 +785,6 @@ if (taskStopNotices.size > 0) taskStopNotices = new Map(); } - async function startLogin() { - if (!loginCheckId || !canLogin || agentLogin.running) return; - try { - const outcome = await startAgentLogin(loginCheckId); - // A signed-in agent changes the check the "Log in" button depends on; a - // cancelled login changes nothing. - if (outcome === 'completed') void runChecks(); - } catch { - // The failure is on the shared login record, which the alert renders. - } - } - function isComposerFocused(): boolean { return document.activeElement === inputEl; } @@ -2398,9 +2328,9 @@ check passing (its probe can't see an expired token), so `Fix` is the fallback, not the answer. -->
- {#if canLogin} - {/if}