fix: 承認通知のクラッシュと質問ツールの行き止まりを解消#84
Conversation
…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
API 34 emulator screenshot failureDiagnostic logs were uploaded as the |
`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
|
原因失敗箇所は composeRule.onNodeWithText("project").assertIsDisplayed()これは入力欄の横にあったワークスペースチップを見ています。しかし そのためこのアサーションは以後どうやっても通らず、実際 #80 / #81 / #82 / #83 すべてで 同一の 修正存在しないチップの代わりに、コンポーザー自身のプレースホルダーを検証するようにしました。
Generated by Claude Code |
API 34 emulator screenshot failureDiagnostic logs were uploaded as the |
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
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
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
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
対象の不具合
1. 承認通知のクラッシュ
原因
PermissionActionReceiverは通知アクションを受けてrespondToPermissionを呼びますが、素のscope.launch { try { … } finally { … } }の中で実行していました(catchなし)。この経路は現実的な失敗が全部例外になります。LocalOpenCodeBackend.delegate()— Android ローカルランタイム未インストール時にIllegalStateExceptionOpenCodeApiClient.respondPermission— 4xx(アプリ内で既に回答済みの承認が最も多い)や通信断でOpenCodeApiExceptionSupervisorJobスコープにCoroutineExceptionHandlerもないため、例外がコルーチンの外へ抜けてスレッドの既定ハンドラに到達し、プロセスごと落ちていました。修正
runCatchingで包み、スコープにCoroutineExceptionHandlerを付与goAsync()の生存時間に合わせて 8秒のタイムアウトを設定(応答が固まってもブロードキャスト枠を超えない)pending.finish()の二重呼び出しでも落ちないよう保護4xx を誘発していた「消えない通知」も修正
承認をアプリ内で回答しても通知が残るため、後からその通知を押すと確実に 4xx → クラッシュしていました。
RuntimeActivityRepository.resolvePermissionを通り、そこで通知をキャンセルするようにしましたpermission.repliedイベントが存在しないため、新設のresolvedPermissionsフローで解決済み ID を配信し、チャット画面に残っていた承認カードも消えるようにしましたついでに直した通知タップの不整合
通知の Intent はセッション ID を
session_idで渡していましたが、MainActivityが読むキーはtarget_session_idでした。そのため通知本体をタップしても該当セッションへ飛ばない状態だったので、Activity 向け Intent は Activity が読むキーを使うようにしました。2. 質問ツール
自由記入がなかった
QuestionCardはprompt.options.isEmpty() || prompt.placeholder != nullのときだけテキスト入力を描画していました。つまり選択肢付きの質問には選択肢以外を答える手段がありませんでした。常に自由入力欄を出すようにし、選択肢がある場合は「その他(自由入力)」ラベルを添えています。回答の正規化ロジック(
updateQuestionAnswerSelection/sanitizeQuestionAnswer)は元から選択肢と併存する自由入力を扱えるため、ロジック側の変更は不要でした。回答する以外の選択肢がなかった
カードには「続ける」ボタンしかなく、回答するまで無効。閉じる手段もありませんでした。逃げ道を2つ用意しています。
abortSession)× は画面からカードを消すだけで、OpenCode 側には何も送りません。質問 API に「拒否」応答が無いため、ターンを終わらせたい場合は中断が唯一の正直な手段になります。
検証
./gradlew :app:testDebugUnitTest— 250件成功 / 失敗0./gradlew :app:lintDebug :app:assembleDebug detekt spotlessCheck— 成功追加したテスト:
dismissing a question only hides the card and leaves the turn runningcancelling a question drops the card and stops the waiting turnfree text answer is accepted even when the question offers optionsresolving a permission cancels its notification and tells other surfacespermission 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