Offer Claude sign-in from failed sessions
This commit is contained in:
1 parent
0be15adbee
commit
f0661919bb
9 files changed
+140
-12
No files matched your search
@@ -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
|
waits for the single producer. Different machines and providers proceed
|
||||||
independently.
|
independently.
|
||||||
|
|
||||||
When Claude cannot renew an expired login, the phone offers sign-in from both
|
When Claude cannot renew an expired login, the driver records that as an
|
||||||
the Machines tab and the usage dialog. The backend starts the configured
|
actionable authentication event rather than leaving the phone to recognise
|
||||||
Claude CLI's headless `auth login` on that machine, returns only its Anthropic
|
the CLI's error sentence. The phone opens sign-in directly over the affected
|
||||||
authorization URL, and accepts the one code copied back from the browser. The
|
session, and also offers it from the Machines tab and the usage dialog. The
|
||||||
CLI remains the OAuth client and the only credential writer: the backend keeps
|
backend starts the configured Claude CLI's headless `auth login` on that
|
||||||
the URL and code only for the live attempt and never sees or persists an access
|
machine, returns only its Anthropic authorization URL, and accepts the one code
|
||||||
token or refresh token. An explicit login holds
|
copied back from the browser. The CLI remains the OAuth client and the only
|
||||||
the same machine/provider gate as usage refresh, is cancelable, expires after
|
credential writer: the backend keeps the URL and code only for the live attempt
|
||||||
ten minutes, and is killed when the backend stops.
|
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
|
**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
|
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 LimitReached(val resetsAt: Double?) : SessionEvent()
|
||||||
|
|
||||||
|
data class AuthenticationRequired(val message: String) : SessionEvent()
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,6 +305,8 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
SessionEvent.LimitReached(
|
SessionEvent.LimitReached(
|
||||||
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||||
)
|
)
|
||||||
|
"authenticationRequired" ->
|
||||||
|
SessionEvent.AuthenticationRequired(body.getString("message"))
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
@@ -319,6 +323,19 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
*/
|
*/
|
||||||
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
|
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.
|
* The context after [event], given what it was before.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ fun SessionScreen(
|
|||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
var historyError by remember(address, epoch) { mutableStateOf<String?>(null) }
|
var historyError by remember(address, epoch) { mutableStateOf<String?>(null) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
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].
|
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
|
||||||
val replies = remember(address) { ParsedReplies() }
|
val replies = remember(address) { ParsedReplies() }
|
||||||
// Keyed like everything else describing one transcript. `rememberLazyListState` saves through
|
// Keyed like everything else describing one transcript. `rememberLazyListState` saves through
|
||||||
@@ -535,6 +538,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
status = event.state
|
status = event.state
|
||||||
}
|
}
|
||||||
|
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
|
||||||
// In order, always: one late event recorded ahead of the backlog would fold a
|
// 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.
|
// 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
|
// 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) {
|
if (settingsOpen) {
|
||||||
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
// 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
|
// 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.MessageDropped -> items
|
||||||
is SessionEvent.Settings -> items
|
is SessionEvent.Settings -> items
|
||||||
is SessionEvent.Status -> settleReply(items, event.state)
|
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.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||||
is SessionEvent.Image ->
|
is SessionEvent.Image ->
|
||||||
// Under the call that produced it when there is one, and a row of its own when there is
|
// 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>) =
|
private fun texts(items: List<TranscriptItem>) =
|
||||||
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
|
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
|
@Test
|
||||||
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
|
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
|
||||||
val items =
|
val items =
|
||||||
|
|||||||
@@ -367,10 +367,20 @@ impl Translator {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
let said = message.get("result").and_then(Value::as_str);
|
let said = message.get("result").and_then(Value::as_str);
|
||||||
events.push(match said.and_then(usage_limit) {
|
events.push(match said {
|
||||||
Some(resets_at) => Event::LimitReached { resets_at },
|
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 {
|
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<Option<f64>> {
|
|||||||
Some(stamp)
|
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
|
/// 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
|
/// 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.
|
/// 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.
|
/// 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
|
/// The naive reading -- an error result like any other -- is what shipped
|
||||||
|
|||||||
@@ -440,6 +440,14 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
resets_at: Option<f64>,
|
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 {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -873,6 +873,9 @@ mod tests {
|
|||||||
tokens: 42,
|
tokens: 42,
|
||||||
context: Some(42),
|
context: Some(42),
|
||||||
},
|
},
|
||||||
|
Event::AuthenticationRequired {
|
||||||
|
message: "sign in again".into(),
|
||||||
|
},
|
||||||
Event::Error {
|
Event::Error {
|
||||||
message: "boom".into(),
|
message: "boom".into(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user