diff --git a/PLAN.md b/PLAN.md index bdd9483..be3ad95 100644 --- a/PLAN.md +++ b/PLAN.md @@ -759,15 +759,17 @@ it. A concurrent read serves the last answer when there is one; a first read waits for the single producer. Different machines and providers proceed independently. -When Claude cannot renew an expired login, the phone offers sign-in from both -the Machines tab and the usage dialog. The backend starts the configured -Claude CLI's headless `auth login` on that machine, returns only its Anthropic -authorization URL, and accepts the one code copied back from the browser. The -CLI remains the OAuth client and the only credential writer: the backend keeps -the URL and code only for the live attempt and never sees or persists an access -token or refresh token. An explicit login holds -the same machine/provider gate as usage refresh, is cancelable, expires after -ten minutes, and is killed when the backend stops. +When Claude cannot renew an expired login, the driver records that as an +actionable authentication event rather than leaving the phone to recognise +the CLI's error sentence. The phone opens sign-in directly over the affected +session, and also offers it from the Machines tab and the usage dialog. The +backend starts the configured Claude CLI's headless `auth login` on that +machine, returns only its Anthropic authorization URL, and accepts the one code +copied back from the browser. The CLI remains the OAuth client and the only +credential writer: the backend keeps the URL and code only for the live attempt +and never sees or persists an access token or refresh token. An explicit login +holds the same machine/provider gate as usage refresh, is cancelable, expires +after ten minutes, and is killed when the backend stops. **The five-hour window has no reset time between blocks, and that is not a missing value.** Measured 2026-08-31: the API anchors the window to the block diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index d8415d5..edad1e8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -192,6 +192,8 @@ sealed class SessionEvent { */ data class LimitReached(val resetsAt: Double?) : SessionEvent() + data class AuthenticationRequired(val message: String) : SessionEvent() + data class Error(val message: String) : SessionEvent() /** @@ -303,6 +305,8 @@ fun parseSeqEvent(json: String): SeqEvent { SessionEvent.LimitReached( if (body.has("resetsAt")) body.getDouble("resetsAt") else null ) + "authenticationRequired" -> + SessionEvent.AuthenticationRequired(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message")) else -> SessionEvent.Unknown(type) } @@ -319,6 +323,19 @@ fun parseSeqEvent(json: String): SeqEvent { */ fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" +/** Whether the latest events still say this session needs an explicit provider login. */ +internal fun authenticationPromptAfter(open: Boolean, event: SessionEvent): Boolean = + when (event) { + is SessionEvent.AuthenticationRequired -> true + // A later provider response proves an older authentication failure in a replayed page is + // no longer current. Without this, one old failure reopened sign-in after every later + // successful turn. + is SessionEvent.AssistantText, + is SessionEvent.AssistantTextFinal, + is SessionEvent.ToolStart -> false + else -> open + } + /** * The context after [event], given what it was before. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 785f5b8..741e6d2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -387,6 +387,9 @@ fun SessionScreen( var loadingHistory by remember { mutableStateOf(false) } var historyError by remember(address, epoch) { mutableStateOf(null) } var ready by remember { mutableStateOf(false) } + // The driver owns recognising provider prose; this screen responds only to its actionable + // event. + var loginOpen by remember(address) { mutableStateOf(false) } // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. val replies = remember(address) { ParsedReplies() } // Keyed like everything else describing one transcript. `rememberLazyListState` saves through @@ -535,6 +538,7 @@ fun SessionScreen( } status = event.state } + if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event) // In order, always: one late event recorded ahead of the backlog would fold a // streamed delta into whatever row happened to be last by then. // Read on the UI thread (the stream below marshals every frame here). This direct @@ -2061,6 +2065,19 @@ fun SessionScreen( ) } } + if (loginOpen) { + ProviderLoginDialog( + settings = settings, + machineId = summary.machine, + machineName = summary.machineName, + provider = summary.provider, + onDismiss = { loginOpen = false }, + onSignedIn = { + loginOpen = false + usageFeed?.refresh?.invoke() + }, + ) + } if (settingsOpen) { // Measured when the dialog opens rather than kept up to date: what the reader is being told // is what pressing the button now would discard, and null until the walk of the directory diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index c2b163b..657b547 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -518,6 +518,8 @@ fun foldEvent(items: List, entry: SeqEvent): List items is SessionEvent.Settings -> items is SessionEvent.Status -> settleReply(items, event.state) + is SessionEvent.AuthenticationRequired -> + items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Image -> // Under the call that produced it when there is one, and a row of its own when there is diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/AuthenticationPromptTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/AuthenticationPromptTest.kt new file mode 100644 index 0000000..f5a2866 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/AuthenticationPromptTest.kt @@ -0,0 +1,28 @@ +package com.example.aiapp + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AuthenticationPromptTest { + @Test + fun an_authentication_failure_stays_actionable_through_its_terminal_status() { + val required = + authenticationPromptAfter( + false, + SessionEvent.AuthenticationRequired("sign in again"), + ) + + assertTrue(authenticationPromptAfter(required, SessionEvent.Status("idle"))) + } + + @Test + fun a_later_provider_response_makes_an_old_failure_stale() { + assertFalse( + authenticationPromptAfter( + true, + SessionEvent.AssistantText("Working again."), + ) + ) + } +} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt index e5d1e8b..185fa7f 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt @@ -21,6 +21,21 @@ class TranscriptItemsTest { private fun texts(items: List) = items.filterIsInstance().map { it.text } + @Test + fun an_authentication_failure_stays_visible_as_an_error_row() { + val entry = + SeqEvent( + seq = 7, + ts = 1.0, + event = SessionEvent.AuthenticationRequired("sign in again"), + ) + + assertEquals( + TranscriptItem.ErrorMsg(7, "sign in again"), + foldEvent(emptyList(), entry).single(), + ) + } + @Test fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() { val items = diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index bbae2a4..c15abcf 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -367,10 +367,20 @@ impl Translator { .unwrap_or(false) { let said = message.get("result").and_then(Value::as_str); - events.push(match said.and_then(usage_limit) { - Some(resets_at) => Event::LimitReached { resets_at }, + events.push(match said { + Some(message) if authentication_required(message) => { + Event::AuthenticationRequired { + message: message.to_string(), + } + } + Some(message) => match usage_limit(message) { + Some(resets_at) => Event::LimitReached { resets_at }, + None => Event::Error { + message: message.to_string(), + }, + }, None => Event::Error { - message: said.unwrap_or("the turn ended with an error").to_string(), + message: "the turn ended with an error".to_string(), }, }); } @@ -1171,6 +1181,13 @@ fn usage_limit(result: &str) -> Option> { Some(stamp) } +/// Claude Code's actionable login failure, kept here with its other dialect strings. +fn authentication_required(message: &str) -> bool { + message + .to_ascii_lowercase() + .contains("oauth session expired and could not be refreshed") +} + /// A string field that is there and not empty, or `None`. The CLI omits these /// rather than sending them empty, but a caller that sends `""` means the same /// thing and should not produce a description that draws as a blank line. @@ -2482,6 +2499,25 @@ mod tests { ); } + #[test] + fn an_expired_login_is_actionable_above_the_driver() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir)); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Failed to authenticate: OAuth session expired and could not be refreshed","usage":{}}"#, + ], + ); + assert_eq!( + events[0], + Event::AuthenticationRequired { + message: "Failed to authenticate: OAuth session expired and could not be refreshed" + .to_string() + } + ); + } + /// Running out of quota is a state, not a failure of the work. /// /// The naive reading -- an error result like any other -- is what shipped diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 9de780e..b15d041 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -440,6 +440,14 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] resets_at: Option, }, + /// The provider refused the turn because its login can no longer be used. + /// + /// Recognised by the driver for the same reason [`Event::LimitReached`] is: + /// only that layer knows the provider's dialect, and the phone needs a + /// state it can act on without matching error prose. + AuthenticationRequired { + message: String, + }, Error { message: String, }, diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 1b26d5c..4ca8884 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -873,6 +873,9 @@ mod tests { tokens: 42, context: Some(42), }, + Event::AuthenticationRequired { + message: "sign in again".into(), + }, Event::Error { message: "boom".into(), },