Skip to content

fix: 承認通知のクラッシュと質問ツールの行き止まりを解消#84

Merged
yuga-hashimoto merged 4 commits into
mainfrom
claude/app-crash-question-tool-fixes-yfhkco
Jul 25, 2026
Merged

fix: 承認通知のクラッシュと質問ツールの行き止まりを解消#84
yuga-hashimoto merged 4 commits into
mainfrom
claude/app-crash-question-tool-fixes-yfhkco

Conversation

@yuga-hashimoto

@yuga-hashimoto yuga-hashimoto commented Jul 25, 2026

Copy link
Copy Markdown
Owner

対象の不具合

  1. 承認ツールの通知で「許可」を押すとクラッシュする
  2. 質問ツールに回答する以外の選択肢がない / 自由記入の回答ができない

1. 承認通知のクラッシュ

原因

PermissionActionReceiver は通知アクションを受けて respondToPermission を呼びますが、素の scope.launch { try { … } finally { … } } の中で実行していました(catch なし)。この経路は現実的な失敗が全部例外になります。

  • LocalOpenCodeBackend.delegate() — Android ローカルランタイム未インストール時に IllegalStateException
  • OpenCodeApiClient.respondPermission — 4xx(アプリ内で既に回答済みの承認が最も多い)や通信断で OpenCodeApiException

SupervisorJob スコープに CoroutineExceptionHandler もないため、例外がコルーチンの外へ抜けてスレッドの既定ハンドラに到達し、プロセスごと落ちていました。

修正

  • 処理全体を runCatching で包み、スコープに CoroutineExceptionHandler を付与
  • goAsync() の生存時間に合わせて 8秒のタイムアウトを設定(応答が固まってもブロードキャスト枠を超えない)
  • pending.finish() の二重呼び出しでも落ちないよう保護

4xx を誘発していた「消えない通知」も修正

承認をアプリ内で回答しても通知が残るため、後からその通知を押すと確実に 4xx → クラッシュしていました。

  • どこで回答しても RuntimeActivityRepository.resolvePermission を通り、そこで通知をキャンセルするようにしました
  • OpenCode には permission.replied イベントが存在しないため、新設の resolvedPermissions フローで解決済み ID を配信し、チャット画面に残っていた承認カードも消えるようにしました
  • 応答が本当に失敗した場合は承認通知を残したまま、「送信できなかった」ことを伝える通知を出します

ついでに直した通知タップの不整合

通知の Intent はセッション ID を session_id で渡していましたが、MainActivity が読むキーは target_session_id でした。そのため通知本体をタップしても該当セッションへ飛ばない状態だったので、Activity 向け Intent は Activity が読むキーを使うようにしました。

2. 質問ツール

自由記入がなかった

QuestionCardprompt.options.isEmpty() || prompt.placeholder != null のときだけテキスト入力を描画していました。つまり選択肢付きの質問には選択肢以外を答える手段がありませんでした

常に自由入力欄を出すようにし、選択肢がある場合は「その他(自由入力)」ラベルを添えています。回答の正規化ロジック(updateQuestionAnswerSelection / sanitizeQuestionAnswer)は元から選択肢と併存する自由入力を扱えるため、ロジック側の変更は不要でした。

回答する以外の選択肢がなかった

カードには「続ける」ボタンしかなく、回答するまで無効。閉じる手段もありませんでした。逃げ道を2つ用意しています。

操作 回答 ターン 用途
×(カード右上) 送らない 走ったまま 質問を無視して普通にメッセージを送りたいとき
回答せず中断 送らない 停止(abortSession このターンをやめたいとき
続ける 送る 継続 通常の回答

× は画面からカードを消すだけで、OpenCode 側には何も送りません。質問 API に「拒否」応答が無いため、ターンを終わらせたい場合は中断が唯一の正直な手段になります。

検証

  • ./gradlew :app:testDebugUnitTest250件成功 / 失敗0
  • ./gradlew :app:lintDebug :app:assembleDebug detekt spotlessCheck — 成功
  • CI 全チェック緑(test-and-build / capture / commitlint / check-translations)

追加したテスト:

  • dismissing a question only hides the card and leaves the turn running
  • cancelling a question drops the card and stops the waiting turn
  • free text answer is accepted even when the question offers options
  • resolving a permission cancels its notification and tells other surfaces
  • permission answered from the notification removes the chat card

実機・エミュレータでの動作確認は未実施です(この環境にエミュレータがありません)。通知アクションからの承認と、選択肢付き質問への自由記入・× での無視は端末での確認をお願いします。

付随して直したCI(本題とは別)

capture(エミュレータスクリーンショット)は 7/23 以降どのPRでも失敗し続けていましたUiScreenshotInstrumentedTest が、UI改修で削除された要素を検証したままだったためです。参照コードが1つも残っていない孤児リソースを見ていたので、現在描画されているものへ差し替えました。

  • 01-chat-empty — ワークスペースチップ(5e290b1 で削除)→ コンポーザーのプレースホルダー
  • 09-provider-settings — 「保存済みの認証情報」「APIキーを追加・更新」(画面刷新で消滅)→ 検索欄とプロバイダ行
  • 10-voice-settings — 「ウェイクワード用の追加パックはまだ導入されていません。」(削除済み)→ ウェイクワード行の説明文

これで #80 以降で初めて capture が緑になりました。

補足

新規文字列4件は values / values-ja に加え、既存の ar / es / fr / pt-rBR / ru / zh-rCN すべてに追加済みです(キー欠落なし)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba

…stion tool

Approving from the notification
- `PermissionActionReceiver` ran `respondToPermission` in a bare
  `scope.launch { try { … } finally { … } }`. Every realistic failure on that
  path throws: `LocalOpenCodeBackend.delegate()` raises when the on-device
  runtime is not installed, and `respondPermission` raises `OpenCodeApiException`
  on any 4xx (most commonly a request that was already answered in the app) or
  on a network error. Nothing caught it, so the exception escaped the coroutine
  and took the process down. The whole body is now wrapped, the scope carries a
  `CoroutineExceptionHandler`, `goAsync().finish()` cannot throw, and the call is
  bounded by an 8s timeout so a hung request never outlives the broadcast window.
- The stale-notification case that triggers the 4xx is fixed too: answering a
  request anywhere (chat card, activity screen, notification) now runs through
  `RuntimeActivityRepository.resolvePermission`, which cancels the notification
  and publishes the id on a new `resolvedPermissions` flow. OpenCode emits no
  `permission.replied` event, so the chat screen previously kept showing a card
  for a request that was already settled; it now drops it.
- When the response genuinely fails, the approval notification is kept and a
  short error notification explains that the tap did not get through.
- Notification taps also passed the session id under `session_id`, but
  `MainActivity` reads `target_session_id`, so opening a notification never
  jumped to its session. Activity intents now use the key the activity reads.

Question tool
- The free-text field only rendered when a prompt had no options or carried an
  explicit placeholder, so a question with choices could not be answered with
  anything but one of those choices. It is now always offered, labelled as a
  free-form alternative when options are present. The existing answer
  sanitisation already supported a typed fallback alongside options.
- The card had exactly one action, disabled until answered, and no way out.
  Added a cancel action: it drops the card and aborts the turn that is waiting
  on the answer, which is the only honest escape since the question API has no
  "declined" reply.

Tests: `testDebugUnitTest` (249 pass), `lintDebug`, `assembleDebug`, `detekt`,
`spotlessCheck`. New coverage for the cancel path, free-text answers alongside
options, the resolved-permission fan-out, and the chat card dropping a
permission answered from the notification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba
@github-actions

Copy link
Copy Markdown
Contributor

API 34 emulator screenshot failure

Diagnostic logs were uploaded as the emulator-ui-diagnostics-30152735693 workflow artifact.

INSTRUMENTATION_STATUS: class=com.opencode.android.ui.UiScreenshotInstrumentedTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
com.opencode.android.ui.UiScreenshotInstrumentedTest:
INSTRUMENTATION_STATUS: test=captureReviewedScreens
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.opencode.android.ui.UiScreenshotInstrumentedTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stack=java.lang.AssertionError: Assert failed: The component is not displayed!
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:34)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens$lambda$0(UiScreenshotInstrumentedTest.kt:103)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.$r8$lambda$Rtp34o0V-2Q6qCYNPZSXovrWpP8(Unknown Source:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest$$ExternalSyntheticLambda7.invoke(D8$$SyntheticClass:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.capture(UiScreenshotInstrumentedTest.kt:366)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens(UiScreenshotInstrumentedTest.kt:100)

INSTRUMENTATION_STATUS: stream=
Error in captureReviewedScreens(com.opencode.android.ui.UiScreenshotInstrumentedTest):
java.lang.AssertionError: Assert failed: The component is not displayed!
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:34)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens$lambda$0(UiScreenshotInstrumentedTest.kt:103)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.$r8$lambda$Rtp34o0V-2Q6qCYNPZSXovrWpP8(Unknown Source:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest$$ExternalSyntheticLambda7.invoke(D8$$SyntheticClass:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.capture(UiScreenshotInstrumentedTest.kt:366)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens(UiScreenshotInstrumentedTest.kt:100)

INSTRUMENTATION_STATUS: test=captureReviewedScreens
INSTRUMENTATION_STATUS_CODE: -2
INSTRUMENTATION_RESULT: stream=

Time: 3.328
There was 1 failure:
1) captureReviewedScreens(com.opencode.android.ui.UiScreenshotInstrumentedTest)
java.lang.AssertionError: Assert failed: The component is not displayed!
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:34)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens$lambda$0(UiScreenshotInstrumentedTest.kt:103)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.$r8$lambda$Rtp34o0V-2Q6qCYNPZSXovrWpP8(Unknown Source:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest$$ExternalSyntheticLambda7.invoke(D8$$SyntheticClass:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.capture(UiScreenshotInstrumentedTest.kt:366)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens(UiScreenshotInstrumentedTest.kt:100)

FAILURES!!!
Tests run: 1,  Failures: 1


INSTRUMENTATION_CODE: -1

`captureReviewedScreens` asserted that a node with the text "project" — the
composer's workspace chip — was displayed on the empty chat screen. The paseo
composer redesign (5e290b1) narrowed that button row to attach / model /
thinking … mic / send and dropped the workspace chip; `CompactWorkspaceButton`
has been dead code ever since, so the assertion could never pass again. The
emulator screenshot job has failed identically on every PR since (#80, #81,
#82, #83 all show the same `UiScreenshotInstrumentedTest.kt:103` failure and
were merged anyway).

Assert the composer's own placeholder instead, which keeps the screen's intent
covered — 02-runtime-not-ready already asserts the same text is *absent* while
the runtime is not ready, so the two cases now check the same element from
both sides.

Verified `:app:compileDebugAndroidTestKotlin`. The emulator run itself could
not be reproduced locally (no emulator in this environment).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba

Copy link
Copy Markdown
Owner Author

capture の失敗はこのPRの変更が原因ではなく、main から引き継いだ既存の失敗でした。修正して push しています(a52caa6)。

原因

失敗箇所は UiScreenshotInstrumentedTest.kt:103:

composeRule.onNodeWithText("project").assertIsDisplayed()

これは入力欄の横にあったワークスペースチップを見ています。しかし 5e290b1(paseo風コンポーザー再設計)でボタン行が attach / model / thinking … mic / send に絞られ、ワークスペースチップは削除されました。CompactWorkspaceButton はそれ以降どこからも呼ばれていない dead code です。

そのためこのアサーションは以後どうやっても通らず、実際 #80 / #81 / #82 / #83 すべてで 同一の UiScreenshotInstrumentedTest.kt:103 失敗が出たままマージされています(例: #83同じスタックトレース)。

修正

存在しないチップの代わりに、コンポーザー自身のプレースホルダーを検証するようにしました。02-runtime-not-ready は「ランタイム未準備のとき同じ文字列が出ていないこと」を既に検証しているので、両ケースで同じ要素を表裏から見る形になります。

:app:compileDebugAndroidTestKotlin は成功を確認済みです。エミュレータ実行自体はこの環境に無いため再現できていないので、capture ジョブの結果を待ちます。


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

API 34 emulator screenshot failure

Diagnostic logs were uploaded as the emulator-ui-diagnostics-30153279626 workflow artifact.

INSTRUMENTATION_STATUS: class=com.opencode.android.ui.UiScreenshotInstrumentedTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stream=
com.opencode.android.ui.UiScreenshotInstrumentedTest:
INSTRUMENTATION_STATUS: test=captureReviewedScreens
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.opencode.android.ui.UiScreenshotInstrumentedTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=1
INSTRUMENTATION_STATUS: stack=java.lang.AssertionError: Assert failed: The component is not displayed!
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:34)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens$lambda$10(UiScreenshotInstrumentedTest.kt:264)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.$r8$lambda$lemSSoXI_VTknWJmy2LLiNolSVg(Unknown Source:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest$$ExternalSyntheticLambda16.invoke(D8$$SyntheticClass:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.capture(UiScreenshotInstrumentedTest.kt:369)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens(UiScreenshotInstrumentedTest.kt:262)

INSTRUMENTATION_STATUS: stream=
Error in captureReviewedScreens(com.opencode.android.ui.UiScreenshotInstrumentedTest):
java.lang.AssertionError: Assert failed: The component is not displayed!
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:34)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens$lambda$10(UiScreenshotInstrumentedTest.kt:264)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.$r8$lambda$lemSSoXI_VTknWJmy2LLiNolSVg(Unknown Source:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest$$ExternalSyntheticLambda16.invoke(D8$$SyntheticClass:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.capture(UiScreenshotInstrumentedTest.kt:369)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens(UiScreenshotInstrumentedTest.kt:262)

INSTRUMENTATION_STATUS: test=captureReviewedScreens
INSTRUMENTATION_STATUS_CODE: -2
INSTRUMENTATION_RESULT: stream=

Time: 10.197
There was 1 failure:
1) captureReviewedScreens(com.opencode.android.ui.UiScreenshotInstrumentedTest)
java.lang.AssertionError: Assert failed: The component is not displayed!
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:34)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens$lambda$10(UiScreenshotInstrumentedTest.kt:264)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.$r8$lambda$lemSSoXI_VTknWJmy2LLiNolSVg(Unknown Source:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest$$ExternalSyntheticLambda16.invoke(D8$$SyntheticClass:0)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.capture(UiScreenshotInstrumentedTest.kt:369)
	at com.opencode.android.ui.UiScreenshotInstrumentedTest.captureReviewedScreens(UiScreenshotInstrumentedTest.kt:262)

FAILURES!!!
Tests run: 1,  Failures: 1


INSTRUMENTATION_CODE: -1

claude added 2 commits July 25, 2026 10:04
Two more assertions in `captureReviewedScreens` referenced UI that no longer
exists. Both string resources are orphaned — no `R.string` reference to them
remains anywhere in the app:

- `09-provider-settings` looked for the "保存済みの認証情報" / "APIキーを追加・更新"
  sections. `ProviderSettingsScreen` is now a search field over provider rows
  plus a GitHub section, so assert the search placeholder and the preview
  provider's row instead.
- `10-voice-settings` looked for the "ウェイクワード用の追加パックはまだ導入されていません。"
  notice. That notice is gone; the wake word row carries the invocation
  description now.

Verified `:app:compileDebugAndroidTestKotlin`. Checked every remaining literal
asserted in the file against the resources actually referenced by the screens
under test, so these should be the last of the stale ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba
"回答せず中断" ends the run, which is the wrong tool when the user just wants to
ignore the question and type something else. Added a close (×) control in the
question card's top-right that only clears the card: no answer is sent, the
turn keeps running, and the composer is free for a normal message.

The two escapes now differ clearly:
- ×               — hide the card, leave the turn alone
- 回答せず中断     — hide the card and abort the turn

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba
@yuga-hashimoto
yuga-hashimoto marked this pull request as ready for review July 25, 2026 10:39
@yuga-hashimoto
yuga-hashimoto merged commit c8f8bf7 into main Jul 25, 2026
4 checks passed
yuga-hashimoto pushed a commit that referenced this pull request Jul 25, 2026
Ships the approval-notification crash fix and the question tool escapes
(free-text answers alongside options, close-without-answering, and
cancel-and-stop) from #84.

Bumps versionCode/versionName alongside .release-version so the app reports the
version it was released as, following 164b906.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba
yuga-hashimoto pushed a commit that referenced this pull request Jul 25, 2026
Measured on the four pushes to #84 — nearly all of the wall clock is one step
in each workflow, and both are dominated by Gradle recompiling from scratch:

    Android CI      19m36s   of which "Test, lint and build APKs"   18m14s
    capture         17m02s   of which the emulator step             16m15s
                             (#83's log shows the gradle build alone
                              accounting for 16m43s of that step)

The emulator itself is not the problem; building the app twice from cold is.

- Enable the Gradle build cache and parallel execution. `setup-gradle` already
  persists `~/.gradle` from main, but without `org.gradle.caching` only
  dependency jars were reused, never task output — so every run recompiled the
  whole app.
- Run the screenshot workflow on main as well. `setup-gradle` writes its cache
  only from the default branch and keys it per job, so a job that never runs on
  main never has a warm cache to restore. `capture` has always started cold for
  exactly this reason; a run on merge gives pull requests something to restore.
- Stop building the release APK on pull requests. `assembleRelease` runs R8 with
  minification and resource shrinking and is the most expensive task here; the
  published artifact comes from release.yml, and main still builds it on every
  merge, so the coverage is kept.
- Cancel superseded pull request runs. android.yml had no `concurrency` block,
  so all four pushes to #84 ran to completion, three of them pointlessly.

Verified `testDebugUnitTest`, `lintDebug`, `detekt`, `spotlessCheck` locally
with the new Gradle settings, and both workflow files parse. The CI timings
themselves can only be confirmed by the runs this change produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba
yuga-hashimoto pushed a commit that referenced this pull request Jul 25, 2026
It is the most expensive thing in CI (17 minutes per pull request) and it was
not buying a proportional amount. It never compared against golden images —
the PNGs were only uploaded as an artifact for a human to look at, and the one
automated check was a hand-maintained list of Japanese strings expected to be
on screen.

That list drifts. `5e290b1` removed the composer's workspace chip on 23 July
and the assertion for it stayed, so the job failed on #80, #81, #82 and #83 —
all merged with it red — until #84 repaired three separate stale assertions.
And because the assertions run before the capture, a single stale string took
the render smoke test and the screenshots down with it: for two days the job
was pure cost.

Removes the workflow along with `UiScreenshotInstrumentedTest` and the
`PreviewRuntimeTarget` fixture that only it used. The other instrumented tests
(`ChatFlowE2ETest`, `ChatVoiceInstrumentedTest`,
`LocalRuntimeUpdaterInstrumentedTest`) are untouched.

What is lost: nothing checks that the ten main screens still render on a real
Android runtime. If that coverage is wanted back, it should return as a render
check with tag-based assertions rather than screen-specific wording, so it
cannot rot the same way.

Verified `compileDebugAndroidTestKotlin`, `testDebugUnitTest`, `detekt` and
`spotlessCheck` after the removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3eN8AmoAcBDCTEeMTY3ba
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.

2 participants