Skip to content

fix(chat): say why a run is waiting when the provider keeps failing - #399

Merged
yuga-hashimoto merged 2 commits into
mainfrom
claude/issue-397-b70353
Oct 23, 2026
Merged

yuga-hashimoto merged 2 commits into
mainfrom
claude/issue-397-b70353

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Refs #397 ("In chat sometimes it does not respond, stuck on processing/thinking, too slow").

The issue has no logs or backend details, so this fixes the cause that could be confirmed from the code and OpenCode's published SDK types, and it was not reproduced on a device.

Cause. When a model call fails with a retryable error (rate limit, overloaded model, dropped connection), OpenCode keeps the turn open and waits out a backoff, announcing it only as session.status of type retry with attempt and the provider's message. The parser kept the type and dropped the rest, the chat treated it like busy, and the screen stayed on "Thinking…" for as long as the retries lasted (there is no upper bound). After 150 s the stall card then said the run produced nothing and had probably stopped, although the runtime was alive and had said exactly why it was waiting.

Fix.

  • OpenCodeEvent.SessionStatusChanged gains retry: RunRetry? (attempt, message); the V1 parser fills it. Existing two-argument constructions are unchanged.
  • ChatUiState.retry holds it until the run produces output or ends (it is deliberately kept across the busy that starts each attempt, so the card does not flicker back to "Thinking"). A new prompt starts without it.
  • The chat shows a card with the provider's reason in place of the "Thinking…"/"Processing…" labels.
  • The stall diagnosis gets PROVIDER_RETRYING: it outranks "no output" but not a finished turn, a recorded provider failure, an unanswered approval/question or a dead event stream, and is not painted as a stopped run.
  • Strings added to all 8 locales (chat_retry_*, chat_stall_reason_provider_retrying).

Not changed (found while investigating, separate causes, left for follow-ups):

  • The background watchdog in RuntimeActivityRepository does not know about retries, so with the chat closed a retrying run is still announced as "probably stopped" and eventually settled. Not made worse by this PR.
  • The OpenCode SSE client uses readTimeout(0) and has no heartbeat check, so a silently dead connection is never reconnected.
  • On stream reconnect (ServerConnected) the chat reloads the transcript but does not settle a turn that finished while it was disconnected; only the stall watchdog (about 3 minutes) does.
  • A stale retry (reconnected but silent) is reported as PROVIDER_RETRYING rather than NO_OUTPUT; keeping next and ignoring an overdue retry would tighten this.

If the reporter or you can say which backend the freezes were seen on (OpenCode vs Codex / Claude Code / Antigravity), that tells us whether the items above are needed. This change only affects OpenCode.

Test plan

  • ./gradlew detekt spotlessCheck passes
  • ./gradlew :app:testGithubDebugUnitTest (1404 tests, 0 failures, 8 skipped) and :app:assembleGithubDebug pass
  • New tests: parser (retry with details / without), diagnosis priority (retry vs finished turn, dead stream), ChatViewModel (retry kept, cleared on output / run end / stop / replacement prompt, stall explained as a retry)
  • i18n key parity checked locally against .github/workflows/i18n-check.yml
  • Manual: point the app at an OpenCode server whose provider is rate-limited and confirm the retry card replaces "Thinking…"

Pre-PR review

The named repo-reviewer subagent (.opencode/agents/repo-reviewer.md) is not registered in Claude Code, so the review was run by a read-only general-purpose subagent given that definition verbatim. It reviewed commit 30411535; commit c462f07a afterwards applied two of its non-blocking suggestions (a 4-line cap on the stall card's detail text, and a test that stopping clears the retry) and nothing else.

判定: APPROVE

## ブロッカー
なし

## 提案(非ブロッキング)
- ChatHomeScreen.kt:1223-1227 — `ChatStallCard` の supportingDetail の `Text` に `maxLines` と `overflow` がありません。この PR は `PROVIDER_RETRYING` の detail に、プロバイダーの生のメッセージ(`ChatRetryCard` 側のコメントどおり、JSON エラーボディ全体のことがある)を載せるようにしました。`ChatRetryCard` では `maxLines = 4` で抑えていますが、150秒後にスタールカードへ切り替わると上限が消え、カードが縦に伸び続けます。`maxLines = 4, overflow = TextOverflow.Ellipsis` を足すか、`supportingDetail()` 側で長さを切り詰めるのが安全です。
- RuntimeActivityRepository.kt:321-331 — バックグラウンド側の watchdog は `retry` を `StallEvidence` に渡さないので、プロバイダーのリトライ中の run はこちらでは今までどおり `NO_OUTPUT` と診断されます。`SessionStatusChanged("retry")` は `provesRunProgress()` が false なので、活動としても記録されません。しきい値は 300 秒です。結果として、アプリがチャット画面を開いていないときは次の2点が残ります。
  - 通知 `notification_stalled_*` が「おそらく停止した」という文言で出ます。
  - settle grace を過ぎると `settleWedgedSession` で session が idle 扱い・mute され、wake lock が外れます。
  これは今回の変更で悪化したものではありません(`retry` の既定値は null で、`when(diagnosis.reason)` は `else` 分岐に落ちます)。KDoc にも「チャットだけが追跡する」と明記されています。issue #397 の範囲(チャット画面)としては許容できます。ただ同じ誤報が通知側に残るので、`handle()` の `SessionStatusChanged` で session ごとの最新 `retry` を保持し(出力・idle・error でクリア)、`StallEvidence.retry` に渡すフォローアップ issue を立てることを勧めます。
- ChatViewModel.kt:2444-2458 と SessionStallDiagnosis.kt:158 — retry は「出力か run 終了でしかクリアされない」設計(busy を挟んでもチラつかせないため)です。そのため、リトライ後の再試行が接続だけ受理されて無出力のまま固まった場合、150秒後の診断は赤の `NO_OUTPUT`(おそらく停止)ではなく、中立色の `PROVIDER_RETRYING` になります。ユーザーは「停止」や「再確認」を押せるので実害は小さいです。パーサーが捨てている `next`(エポックms)を `RunRetry` に残し、`now > next + 猶予` なら古い retry として診断で無視する、といった保険を後で足せます。優先度は低いです。
- テストの穴(いずれもテストされていません):
  - `sendSlashCommand` 経路(ChatViewModel.kt の2か所目の `retry = null`、約1721行)
  - `abort()` とセッション切替(`loadSession` の `switchingSession`)で retry が消えること
  実装上は `isRunning` の true→false を監視するコレクターが消すので正しく動きます。ただ、将来この監視の仕組みを変えると退行しうるので、1本ずつ足す価値はあります。
- 文言(軽微): `chat_retry_title` は「モデルプロバイダーがエラーを返した」ですが、`RunRetry` の KDoc は「接続断」も想定しています。接続断のときは見出しが少しずれます。詳細メッセージが併記されるので許容範囲です。

## チェック済み項目
- 範囲確定: `git diff --stat origin/main...HEAD`(17ファイル)と、main・res・test の全ハンクをすべて読みました。
- (1) retry のクリア経路: ChatViewModel の `isRunning = false` を設定している全箇所を洗い出して確認しました。対象は `handleSessionIdle`、`SessionError`、`abort()`、`newSession`、`loadSession` の `switchingSession`、`sendMessage` と `sendSlashCommand` の失敗系、PROVIDER_ERROR 判定です。いずれも isRunning の false 化により、`uiState.map{isRunning}.distinctUntilChanged().collectLatest` のコレクター(ChatViewModel.kt:814-819)が `stall` と `retry` を同時にクリアします。
  - isRunning が true のままになる経路もカバーされています。置換プロンプト(interrupt)は `sendMessage` と `sendSlashCommand` の送信時 `copy` で `retry = null` にします。queue 送信は idle 後の `drainQueue` が `sendMessage` に入るので、同じ箇所で消えます。
  - 出力側は `updateStreamingMessage` の両分岐で消えます。user echo の分岐は出力ではないので消さず、これも正しい挙動です。`step-start` などは `toChatPart` が null を返すので、リトライ中に誤ってクリアされません。
  - 他セッションの retry は `event.sessionId != activeSession` で除外されます。
- (2) `diagnoseStall` の優先順位: RUNTIME_UNREACHABLE → providerFailed → turnCompleted → permission → question → stream 切断 → retry → tool → NO_OUTPUT の順です。
  - 古い retry が、完了済み(COMPLETION_MISSED)や失敗済み(PROVIDER_ERROR)の判定を隠すことはありません。
  - `isStopped` と `isTerminal` は PROVIDER_RETRYING で false になるので、スタールカードは中立色で出ます。
  - RuntimeActivityRepository 側の影響は上記の提案2のとおりで、退行はありません。`when(diagnosis.reason)` には `else` があるので、PROVIDER_RETRYING はアナウンス分岐に入ります。`isStopped` が false なので `settleWedgedSession` は呼ばれません。ただし実際には `retry` が渡されないため、この経路に来ることはありません。
- (3) `StallReason` を使う `when`: `StallMessages.explain`(`else` なし・追加済み)、`ChatViewModel.diagnoseSilentRun`(`else` あり)、`RuntimeActivityRepository`(`else` あり)です。ほかは `if` による個別比較で、`values()` や `entries` による列挙はありません。`AnalyticsEvent.runtimeSessionStalled` は `reason.name` を使うだけなので影響ありません。
- `OpenCodeEvent` の網羅: `SessionStatusChanged` はデフォルト引数付きの追加なので、既存の生成箇所(V2 translator、Codex、V2ApiClient)はそのままコンパイルでき、挙動も変わりません。`provesRunProgress`(false のまま)、`sessionIdOrNull`、`AndCodeVoiceSession`(`Unit`)も確認しました。V2 系は retry イベントを出さないので、この機能の対象外です。
- (4) i18n: 追加した4キー(`chat_stall_reason_provider_retrying`、`chat_retry_title`、`chat_retry_attempt`、`chat_retry_hint`)が `values/` と7ロケール(ar, es, fr, ja, pt-rBR, ru, zh-rCN)に各1件あることを `rg -c` で確認しました。`chat_retry_attempt` の `%1$d` は8ファイルすべてにあります。英語ソースにあって各ロケールにないキーを `comm` で全件照合すると、出たのは既存の `translatable="false"` の2件(`analytics_row` と `chat_workspace_attachment_note`)だけでした。Kotlin に直書きの文言はありません(`RunRetry.message` は runtime 由来のデータです)。
- (5) Compose: 分岐は `isRunning && stall` → `isRunning && retry` → `isRunning && timeline` の `else if` 連鎖で排他になっています。`state` は関数パラメータ、`retry` は同一モジュールの data class の `val` なので、`ChatRetryCard(retry = state.retry)` の smart cast は有効です。Thinking チップは `stall == null && retry == null` で隠れます。`isRunning` が false のフレームでも retry は表示されません。`WarningAmber` などの import は既存のものを再利用しています。
- (6) テスト:
  - `OpenCodeEventParserTest` に retry 付きと欠損の2本と、busy で null になることの検証を追加しています。`ChatViewModelStallTest` にも新規5本あります。
  - 非自明性を確認しました。`StallEvidence` に `retry` を渡していなければ NO_OUTPUT になって失敗します。置換プロンプトのテストは、`_sendBehavior` の既定が "interrupt"(ChatViewModel.kt:658)で isRunning が true→true になるため、`retry = null` を外すと失敗します。ストリーム開始でのクリアは `MessagePartDelta` が最終分岐に入って `retry = null` になる経路です。
  - 診断優先順位の2本(完了済み > retry、stream 切断 > retry)は、順序を入れ替えたら落ちます。
- パーサー: `intOrNull`/`contentOrNull` の扱いを確認しました。`JsonNull` は null になり、空白のみの message は `takeIf` で除外されます。`attempt` が欠けると 1 に補われ、`coerceAtLeast(1)` がかかります。`next` を捨てる理由はコメントで説明されています。
- ハードルール: シークレットの混入なし。コメントは非自明な判断(順序の理由、busy を挟んで保持する理由)を説明しています。コミットメッセージは `fix:` 形式で Co-Authored-By もあります。DI の変更は不要で(コンストラクタ引数を追加していない)、ViewModelFactory との同期も必要ありません。
- 実行はしていません(Gradle 禁止のため、spotless/detekt/テストの結果は報告どおりローカル成功を前提としています)。

🤖 Generated with Claude Code

yuga-hashimoto and others added 2 commits October 24, 2026 00:54
When a model call fails with a retryable error (rate limit, overloaded model,
dropped connection), OpenCode keeps the turn open and waits out a backoff,
announcing it only as `session.status` of type `retry` with the attempt number
and the provider's message. The parser read the type and dropped the rest, the
chat treated it like `busy`, and the screen stayed on "Thinking..." for as long
as the retries lasted - which has no upper bound. After 2.5 minutes the stall
card then called the run dead ("producing nothing") although the runtime was
alive and had said exactly why it was waiting.

Keep the retry details (attempt, message) on the status event, hold them in
the chat state until the run produces output or ends, and show a card with the
provider's reason in place of the "Thinking"/"Processing" labels. The stall
diagnosis gets a PROVIDER_RETRYING verdict that outranks "no output" but not a
finished turn or a dead event stream, and is not painted as a stopped run.

Refs #397

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the retry

Applies the non-blocking suggestions from the pre-PR review: the stall card now
shows a retrying run's provider message too, which can be a whole JSON error
body, so the supporting detail gets the same four-line cap as the retry card;
and a test pins that stopping a retrying run drops the retry.

Refs #397

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit f28d8d9 into main Oct 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant