Offer Claude sign-in from failed sessions

This commit is contained in:
iris-ai committed 2026-09-15 12:24:25 -04:00
1 parent 0be15adbee
commit f0661919bb
9 files changed
+139 -11

No files matched your search

+11 -9
View File
@@ -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
@@ -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.
*
@@ -387,6 +387,9 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) }
var historyError by remember(address, epoch) { mutableStateOf<String?>(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
@@ -518,6 +518,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.MessageDropped -> 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
@@ -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."),
)
)
}
}
@@ -21,6 +21,21 @@ class TranscriptItemsTest {
private fun texts(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.AssistantMsg>().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 =
+38 -2
View File
@@ -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) {
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: said.unwrap_or("the turn ended with an error").to_string(),
message: message.to_string(),
},
},
None => Event::Error {
message: "the turn ended with an error".to_string(),
},
});
}
@@ -1171,6 +1181,13 @@ fn usage_limit(result: &str) -> Option<Option<f64>> {
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
+8
View File
@@ -440,6 +440,14 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
resets_at: Option<f64>,
},
/// 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,
},
+3
View File
@@ -873,6 +873,9 @@ mod tests {
tokens: 42,
context: Some(42),
},
Event::AuthenticationRequired {
message: "sign in again".into(),
},
Event::Error {
message: "boom".into(),
},