Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,18 @@ data class QuestionRequest(
val directory: String? = null,
)

/**
* A provider call that failed in a way the runtime retries on its own (a rate limit, an overloaded
* model, a dropped connection). While it waits out the backoff the run produces no output at all,
* which from the chat looks identical to a model that is thinking hard.
*/
data class RunRetry(
/** Which attempt just failed, counting from 1. */
val attempt: Int,
/** The provider's own words for the failure, when the runtime passed them on. */
val message: String?,
)

sealed interface OpenCodeEvent {
data object ServerConnected : OpenCodeEvent

Expand Down Expand Up @@ -408,8 +420,15 @@ sealed interface OpenCodeEvent {
/** A session's metadata changed; carries the same payload as [SessionCreated]. */
data class SessionUpdated(val session: OpenCodeSession) : OpenCodeEvent

/** Replacement for the deprecated `session.idle`: status is `idle`, `busy` or `retry`. */
data class SessionStatusChanged(val sessionId: String, val status: String) : OpenCodeEvent
/**
* Replacement for the deprecated `session.idle`: status is `idle`, `busy` or `retry`. A `retry`
* carries [retry], the provider failure the runtime is waiting out before it tries again.
*/
data class SessionStatusChanged(
val sessionId: String,
val status: String,
val retry: RunRetry? = null,
) : OpenCodeEvent

/**
* A run stopped on purpose — stop button, or interrupted by a replacement prompt — is a decision,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

Expand Down Expand Up @@ -104,11 +106,15 @@ class OpenCodeEventParser(
)
OpenCodeEvent.SessionUpdated(session)
}
"session.status" ->
"session.status" -> {
val status = properties["status"]!!.jsonObject
val statusType = status["type"]!!.jsonPrimitive.content
OpenCodeEvent.SessionStatusChanged(
sessionId = properties["sessionID"]!!.jsonPrimitive.content,
status = properties["status"]!!.jsonObject["type"]!!.jsonPrimitive.content,
status = statusType,
retry = if (statusType == "retry") parseRetry(status) else null,
)
}
"session.error" -> {
val errorObject = properties["error"] as? JsonObject
OpenCodeEvent.SessionError(
Expand All @@ -122,6 +128,18 @@ class OpenCodeEventParser(
}.getOrElse { OpenCodeEvent.Unknown(type, raw) }
}

/**
* `{"type":"retry","attempt":3,"message":"...","next":<epoch millis>}`. A retry status missing
* its details is still a retry, so the fields fall back rather than the whole event being lost
* to [OpenCodeEvent.Unknown]. `next` is not kept: a countdown rendered from it would sit stale
* between events.
*/
private fun parseRetry(status: JsonObject): RunRetry =
RunRetry(
attempt = (status["attempt"] as? JsonPrimitive)?.intOrNull?.coerceAtLeast(1) ?: 1,
message = (status["message"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() },
)

/**
* OpenCode reports session failures as a named error object, e.g.
* `{"name":"ProviderAuthError","data":{"message":"..."}}`. Prefer the human-readable message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.yugahashimoto.andcode.core.diagnostics

import com.yugahashimoto.andcode.core.api.OpenCodeEvent
import com.yugahashimoto.andcode.core.api.OpenCodeMessage
import com.yugahashimoto.andcode.core.api.RunRetry
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
Expand Down Expand Up @@ -33,6 +34,9 @@ enum class StallReason {
/** The event stream is down, so progress may be happening unseen. */
STREAM_DISCONNECTED,

/** The provider keeps failing and the runtime is waiting out its backoff to try again. */
PROVIDER_RETRYING,

/** A tool call has been in flight for the whole silent stretch. */
TOOL_RUNNING,

Expand Down Expand Up @@ -89,6 +93,11 @@ data class StallEvidence(
val streamError: String? = null,
val awaitingPermission: Boolean = false,
val awaitingQuestion: Boolean = false,
/**
* The retry the runtime last announced for this run, if it has not produced anything since.
* Only the chat sees these as they arrive; a caller that does not track them leaves it null.
*/
val retry: RunRetry? = null,
val transcript: RunSignals = RunSignals(),
)

Expand Down Expand Up @@ -143,6 +152,13 @@ fun diagnoseStall(evidence: StallEvidence): StallDiagnosis {
reason = StallReason.STREAM_DISCONNECTED
detail = evidence.streamError
}
// After the stream check because a retry is only known from events: with the stream down it
// may be long out of date. Before the tool check because a retry is what a run is doing
// once its tools have finished and the next model call keeps failing.
evidence.retry != null -> {
reason = StallReason.PROVIDER_RETRYING
detail = evidence.retry.message
}
evidence.transcript.runningTool != null -> {
reason = StallReason.TOOL_RUNNING
detail = evidence.transcript.runningTool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ fun StallDiagnosis.explain(context: Context): String =
StallReason.AWAITING_PERMISSION -> context.getString(R.string.chat_stall_reason_awaiting_permission)
StallReason.AWAITING_QUESTION -> context.getString(R.string.chat_stall_reason_awaiting_question)
StallReason.STREAM_DISCONNECTED -> context.getString(R.string.chat_stall_reason_stream_disconnected)
StallReason.PROVIDER_RETRYING -> context.getString(R.string.chat_stall_reason_provider_retrying)
// A sentence of its own rather than a noun dropped into the one above: "The tool a tool is
// still running" is not a sentence in any of the languages this ships in.
StallReason.TOOL_RUNNING ->
Expand All @@ -27,7 +28,15 @@ fun StallDiagnosis.explain(context: Context): String =
/**
* The runtime's own words for the stall, when they add something the sentence above does not
* already say. A tool name is already in that sentence, and the terminal reasons are reported
* through the normal error surfaces, so only a failed probe leaves anything extra to show.
* through the normal error surfaces, so only a failed probe, or the provider failure a retry is
* waiting out, leaves anything extra to show.
*/
fun StallDiagnosis.supportingDetail(): String? =
detail?.takeIf { it.isNotBlank() && (reason == StallReason.RUNTIME_UNREACHABLE || reason == StallReason.STREAM_DISCONNECTED) }
detail?.takeIf {
it.isNotBlank() &&
(
reason == StallReason.RUNTIME_UNREACHABLE ||
reason == StallReason.STREAM_DISCONNECTED ||
reason == StallReason.PROVIDER_RETRYING
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeModel
import com.yugahashimoto.andcode.core.api.OpenCodeProvider
import com.yugahashimoto.andcode.core.api.OpenCodeSkill
import com.yugahashimoto.andcode.core.api.PromptAttachment
import com.yugahashimoto.andcode.core.api.RunRetry
import com.yugahashimoto.andcode.core.api.defaultAgentName
import com.yugahashimoto.andcode.core.api.selectablePrimaryAgents
import com.yugahashimoto.andcode.core.diagnostics.StallDiagnosis
Expand Down Expand Up @@ -550,6 +551,10 @@ fun ChatHomeScreen(
onStop = onAbort,
)
}
} else if (state.isRunning && state.retry != null) {
// The runtime is waiting out a provider failure. Saying so beats a
// "Processing" label that reads the same as a model that is just slow.
item(key = "retry") { ChatRetryCard(retry = state.retry) }
} else if (state.isRunning && timelineEntries.isNotEmpty()) {
item(key = "processing") {
Text(
Expand All @@ -574,7 +579,7 @@ fun ChatHomeScreen(
// Not while a stall is up: a turn that has produced nothing never
// cleared this flag, so the warning would sit directly above a chip
// cheerfully reporting that the same turn is thinking.
if (state.isThinking && state.stall == null) {
if (state.isThinking && state.stall == null && state.retry == null) {
item { StatusChip(text = stringResource(R.string.thinking), active = true) }
}
state.error?.let { error ->
Expand Down Expand Up @@ -1215,10 +1220,13 @@ private fun ChatStallCard(
Text(text = stall.explain(context))
stall.supportingDetail()?.let { detail ->
Spacer(Modifier.height(4.dp))
// Capped: a provider failure can arrive as a whole JSON error body.
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(12.dp))
Expand All @@ -1244,6 +1252,56 @@ private fun ChatStallCard(
}
}

/**
* Tells the user why a run that is still going is not producing anything: the provider failed and
* the runtime is trying again by itself. A rate-limited or overloaded model can keep this up for a
* long time, and without the card it is indistinguishable from one that is thinking slowly.
*/
@Composable
private fun ChatRetryCard(retry: RunRetry) {
Surface(
modifier = Modifier.testTag("chat-retry-card"),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Text(
text = stringResource(R.string.chat_retry_title),
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.chat_retry_attempt, retry.attempt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
retry.message?.let { message ->
Spacer(Modifier.height(6.dp))
// The provider's own words, capped: some return a whole JSON error body.
Text(text = message, maxLines = 4, overflow = TextOverflow.Ellipsis)
}
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.chat_retry_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

@Composable
private fun ChatComposer(
inputValue: TextFieldValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.yugahashimoto.andcode.core.api.PromptRequest
import com.yugahashimoto.andcode.core.api.PullRequestRef
import com.yugahashimoto.andcode.core.api.QuestionPrompt
import com.yugahashimoto.andcode.core.api.QuestionRequest
import com.yugahashimoto.andcode.core.api.RunRetry
import com.yugahashimoto.andcode.core.api.sessionIdOrNull
import com.yugahashimoto.andcode.core.diagnostics.RunSignals
import com.yugahashimoto.andcode.core.diagnostics.StallDiagnosis
Expand Down Expand Up @@ -545,6 +546,13 @@ data class ChatUiState(
* best available answer to "is this still working, and if not, why not".
*/
val stall: StallDiagnosis? = null,
/**
* Set while the runtime is waiting out a failing provider call before trying it again, and
* cleared by the first output of the run or its end. Without it a rate-limited or overloaded
* model looks exactly like a slow one: the chat sits on "Thinking" for as long as the retries
* last, which has no upper bound.
*/
val retry: RunRetry? = null,
val error: String? = null,
/**
* Set by [ChatViewModel.editLastUserMessage] to the text of the message it just removed, so the
Expand Down Expand Up @@ -806,7 +814,9 @@ class ChatViewModel(
.collectLatest { running ->
if (!running) {
// Whatever the run was doing, it is not doing it any more.
if (_uiState.value.stall != null) _uiState.update { it.copy(stall = null) }
if (_uiState.value.stall != null || _uiState.value.retry != null) {
_uiState.update { it.copy(stall = null, retry = null) }
}
return@collectLatest
}
recordProgress()
Expand Down Expand Up @@ -1491,6 +1501,7 @@ class ChatViewModel(
messages = it.messages + userMessage,
isRunning = true,
isThinking = true,
retry = null,
partialText = "",
error = null,
)
Expand Down Expand Up @@ -1707,6 +1718,7 @@ class ChatViewModel(
messages = it.messages + userMessage,
isRunning = true,
isThinking = true,
retry = null,
partialText = "",
error = null,
)
Expand Down Expand Up @@ -2260,6 +2272,7 @@ class ChatViewModel(
streamError = streamError,
awaitingPermission = state.permissions.isNotEmpty(),
awaitingQuestion = state.pendingQuestions.isNotEmpty(),
retry = state.retry,
transcript = transcript.map(::inspectRun).getOrDefault(RunSignals()),
),
)
Expand Down Expand Up @@ -2439,6 +2452,10 @@ class ChatViewModel(
if (!_uiState.value.isRunning) {
_uiState.update { it.copy(isRunning = true) }
}
// Kept through the busy that starts each new attempt and only cleared by
// output or the end of the run, so the card does not flicker back to
// "Thinking" between failures.
event.retry?.let { retry -> _uiState.update { it.copy(retry = retry) } }
}
}
}
Expand Down Expand Up @@ -2540,7 +2557,7 @@ class ChatViewModel(
isStreaming = !existing.isUser,
)
}
return@update state.copy(messages = updated, isRunning = true, isThinking = false)
return@update state.copy(messages = updated, isRunning = true, isThinking = false, retry = null)
}

val incomingText = parts.filterIsInstance<ChatPart.Text>().joinToString("") { it.text }
Expand All @@ -2564,6 +2581,7 @@ class ChatViewModel(
),
isRunning = true,
isThinking = false,
retry = null,
)
}
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,15 @@
<string name="chat_stall_reason_awaiting_permission">التنفيذ متوقّف بانتظار موافقة. أجب عن طلب الموافقة للمتابعة.</string>
<string name="chat_stall_reason_awaiting_question">التنفيذ متوقّف بانتظار إجابة عن سؤال. أجب عنه للمتابعة.</string>
<string name="chat_stall_reason_stream_disconnected">انقطع اتصال الأحداث، لذا لا يصل التقدّم إلى التطبيق.</string>
<string name="chat_stall_reason_provider_retrying">يواصل مزوّد النموذج إرجاع أخطاء وتعيد بيئة التشغيل المحاولة. سيستمر التنفيذ إذا تعافى المزوّد؛ أوقفه إن كنت لا تريد الانتظار.</string>
<string name="chat_stall_reason_tool_running">الأداة %1$s ما زالت قيد التشغيل ولم تُرجع نتيجة.</string>
<string name="chat_stall_reason_no_output">تُبلّغ بيئة التشغيل بأن التنفيذ جارٍ لكنها لا تُنتج شيئًا. من المرجّح أنه توقّف.</string>
<string name="chat_stall_reason_tool_running_unnamed">ما زالت إحدى الأدوات قيد التشغيل ولم تُرجع نتيجة.</string>
<string name="chat_stall_recheck">إعادة الفحص</string>
<string name="chat_stall_stop">إيقاف التنفيذ</string>
<string name="chat_retry_title">أعاد مزوّد النموذج خطأً</string>
<string name="chat_retry_attempt">تتم إعادة المحاولة تلقائيًا · المحاولة %1$d</string>
<string name="chat_retry_hint">يستمر التنفيذ تلقائيًا إذا تعافى المزوّد. يمكنك إيقافه في أي وقت بزر الإيقاف.</string>
<string name="schedule_run_interrupted">تم إيقاف التشغيل قبل اكتماله</string>
<string name="schedule_completion_timeout">توقّف التشغيل المجدول عن الاستجابة ولم يكتمل</string>
<string name="schedule_max_duration">تجاوز التشغيل المجدول أقصى مدة مسموحة</string>
Expand Down
Loading
Loading