diff --git a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt index 39a1fb5d..a3e8449d 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepository.kt @@ -39,6 +39,7 @@ data class RuntimeActivityState( val completedSessionIds: Set = emptySet(), /** Sessions whose current run has ended; late stream events must not resurrect them. */ val settledSessionIds: Set = emptySet(), + val mutedSessionIds: Set = emptySet(), val permissions: List = emptyList(), val logs: List = emptyList(), val streamError: String? = null, @@ -189,11 +190,22 @@ class RuntimeActivityRepository( activeSessionIds = current.activeSessionIds + sessionId, completedSessionIds = current.completedSessionIds - sessionId, settledSessionIds = current.settledSessionIds - sessionId, + mutedSessionIds = current.mutedSessionIds - sessionId, ) } persistUnread() } + fun markSessionAborted(sessionId: String) { + if (sessionId.isBlank()) return + mutableState.update { current -> + current.copy( + activeSessionIds = current.activeSessionIds - sessionId, + mutedSessionIds = current.mutedSessionIds + sessionId, + ) + } + } + /** Records that a run finished, leaving the chat unread until it is opened. */ fun markSessionFinished( sessionId: String, @@ -251,14 +263,18 @@ class RuntimeActivityRepository( } is OpenCodeEvent.SessionIdle -> { markRuntimeIdle(event.sessionId) + var muted = false mutableState.update { current -> + muted = event.sessionId in current.mutedSessionIds current.copy( activeSessionIds = current.activeSessionIds - event.sessionId, completedSessionIds = current.completedSessionIds + event.sessionId, settledSessionIds = current.settledSessionIds + event.sessionId, + mutedSessionIds = current.mutedSessionIds - event.sessionId, ) } appendLog(messages.eventCompleted, null, event.sessionId) + if (muted) return parentResolutionOf(target, event.sessionId).onSuccess { parentId -> if (parentId == null) { onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId), target.id) @@ -301,6 +317,7 @@ class RuntimeActivityRepository( } else { current.settledSessionIds - event.sessionId }, + mutedSessionIds = current.mutedSessionIds - event.sessionId, ) } if (event.status != "idle") { @@ -314,6 +331,7 @@ class RuntimeActivityRepository( current.copy( activeSessionIds = current.activeSessionIds - sessionId, settledSessionIds = current.settledSessionIds + sessionId, + mutedSessionIds = current.mutedSessionIds + sessionId, ) } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt index aea12a04..132db39e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt @@ -328,6 +328,7 @@ class ChatViewModel( * events arrive. Deriving it from events alone left every chat on the idle marker. */ private val onRunStateChanged: (String, Boolean) -> Unit = { _, _ -> }, + private val onSessionAborted: (String) -> Unit = {}, private val draftRepo: DraftRepository? = null, /** * Starts the periodic connection probe. It runs an unbounded polling loop, which a virtual @@ -793,6 +794,7 @@ class ChatViewModel( _uiState.update { it.copy(attachments = emptyList(), imagePreviews = emptyList()) } return } + val interrupting = _uiState.value.isRunning val userMessage = ChatMessage( @@ -848,6 +850,7 @@ class ChatViewModel( } refreshContextUsage(targetSessionId) } + if (interrupting) onSessionAborted(targetSessionId) currentBackend.sendMessage( targetSessionId, PromptRequest( @@ -1395,6 +1398,7 @@ class ChatViewModel( val currentBackend = backend ?: return val sessionId = _uiState.value.sessionId ?: return viewModelScope.launch { + onSessionAborted(sessionId) runCatching { currentBackend.abortSession(sessionId) } .onSuccess { _uiState.update { diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt index e7ba0cdc..8525a67e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt @@ -9,6 +9,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeTodo import com.yugahashimoto.andcode.core.api.PromptAttachment import com.yugahashimoto.andcode.core.api.QuestionRequest import com.yugahashimoto.andcode.runtime.PermissionResponse +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -69,6 +70,7 @@ class ClaudeCodeRuntime( private class SessionProcess( val process: Process, val readerJob: Job, + val parser: ClaudeStreamJsonParser, val permissionMode: ClaudePermissionMode, val directory: String, val model: String?, @@ -280,6 +282,7 @@ class ClaudeCodeRuntime( ): Result = runCatching { val session = ensureProcess(sessionId, directory.ifBlank { "/workspace" }, permissionMode, model, effort) + session.parser.beginTurn() recordUserMessage(sessionId, prompt, attachments) session.process.outputStream.apply { write((json.encodeToString(JsonObject.serializer(), userMessage(prompt, attachments)) + "\n").toByteArray()) @@ -382,20 +385,33 @@ class ClaudeCodeRuntime( val requestedModel = model val readerJob = scope.launch { - runCatching { - process.inputStream.bufferedReader().forEachLine { line -> - handleLine(sessionId, parser, line, requestedModel) - } - } + val streamFailure = + runCatching { + process.inputStream.bufferedReader().forEachLine { line -> + handleLine(sessionId, parser, line, requestedModel) + } + }.exceptionOrNull() + .takeUnless { it is CancellationException } messageStore.flush() - // A CLI that exits mid-turn would otherwise leave the chat spinning forever. - events.tryEmit(OpenCodeEvent.SessionIdle(sessionId)) synchronized(this@ClaudeCodeRuntime) { if (sessions[sessionId]?.process === process) sessions.remove(sessionId) } + // stop() cancels this job; the abort path owns the state transitions, and an idle + // emitted here would announce the killed run as completed. + if (!isActive) return@launch + // A CLI that exits before its result line would otherwise leave the chat spinning + // forever; that is a failure, not a completion. A finished turn already emitted its + // own idle, and repeating it on process exit would re-announce the old run. + if (!parser.turnFinished) { + val exitCode = runCatching { process.exitValue() }.getOrNull() + events.tryEmit( + OpenCodeEvent.SessionError(sessionId, messages.processExited(exitCode, streamFailure?.message)), + ) + events.tryEmit(OpenCodeEvent.SessionIdle(sessionId)) + } } - return SessionProcess(process, readerJob, effectiveMode, directory, model, effort) + return SessionProcess(process, readerJob, parser, permissionMode, directory, model, effort) .also { sessions[sessionId] = it } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeMessages.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeMessages.kt index 14101a9f..ac333242 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeMessages.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeMessages.kt @@ -19,6 +19,11 @@ interface ClaudeMessages { fun signInExited(exitCode: Int): String + fun processExited( + exitCode: Int?, + detail: String?, + ): String + /** English fallbacks for unit tests and any construction path without a [Context]. */ companion object Default : ClaudeMessages { override val runtimeMissing = "The Linux environment is not installed yet" @@ -29,6 +34,14 @@ interface ClaudeMessages { override val updateFailed = "Claude Code update failed" override fun signInExited(exitCode: Int) = "Claude Code sign-in stopped (exit code $exitCode)" + + override fun processExited( + exitCode: Int?, + detail: String?, + ): String { + val cause = detail ?: exitCode?.let { "exit code $it" } ?: "process exited" + return "Claude Code stopped before finishing the turn ($cause)" + } } } @@ -41,4 +54,12 @@ class AndroidClaudeMessages(private val context: Context) : ClaudeMessages { override val updateFailed get() = context.getString(R.string.claude_error_update_failed) override fun signInExited(exitCode: Int): String = context.getString(R.string.claude_error_sign_in_exit, exitCode) + + override fun processExited( + exitCode: Int?, + detail: String?, + ): String { + val cause = detail ?: exitCode?.let { "exit code $it" } ?: "process exited" + return context.getString(R.string.claude_error_process_exited, cause) + } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt index 851e880b..533b6e43 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt @@ -45,6 +45,14 @@ class ClaudeStreamJsonParser( private var currentMessageId: String? = null + @Volatile + var turnFinished: Boolean = false + private set + + fun beginTurn() { + turnFinished = false + } + /** Tool calls that have not received a matching tool_result from Claude Code yet. */ private val openTools = linkedMapOf() private val messagesById = linkedMapOf() @@ -157,6 +165,7 @@ class ClaudeStreamJsonParser( } private fun parseResult(root: JsonObject): Parsed { + turnFinished = true val claudeSessionId = root.string("session_id") val isError = root["is_error"]?.jsonPrimitive?.contentOrNull == "true" val subtype = root.string("subtype") diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index 2510dddb..f8f2ff7e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -279,6 +279,7 @@ fun AndCodeApp( onPermissionResolved = app.activityRepository::resolvePermission, onQuestionResolved = app.notifications::cancelQuestion, onSessionCreated = app.catalogRepository::refreshSessionsOnly, + onSessionAborted = app.activityRepository::markSessionAborted, onRunStateChanged = { sessionId, running -> if (running) { app.activityRepository.markSessionRunning(sessionId) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8c5aeb70..2f88a042 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -897,6 +897,7 @@ تعذّر إرسال الرمز فشل تثبيت Claude Code فشل تحديث Claude Code + توقف Claude Code قبل إنهاء الدور (%1$s) تحضير بيئة Linux المشتركة والوكلاء الذين اخترتهم. الوكلاء الوكلاء diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e7ebd5f4..98e91436 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -897,6 +897,7 @@ No se pudo enviar el código Falló la instalación de Claude Code Falló la actualización de Claude Code + Claude Code se detuvo antes de terminar el turno (%1$s) Prepara el entorno Linux compartido y los agentes que eligió. Agentes Agentes diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ff0f0d19..35766fd5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -897,6 +897,7 @@ Impossible d’envoyer le code L’installation de Claude Code a échoué La mise à jour de Claude Code a échoué + Claude Code s’est arrêté avant de terminer le tour (%1$s) Prépare l’environnement Linux partagé et les agents que vous avez choisis. Agents Agents diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7731ceac..ea79ba0e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -943,6 +943,7 @@ コードを送信できませんでした Claude Codeのインストールに失敗しました Claude Codeの更新に失敗しました + Claude Codeがターンを完了する前に停止しました(%1$s) 共有のLinux環境と、選んだエージェントを準備します。 エージェント エージェント diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4fecb63e..809a6e64 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -897,6 +897,7 @@ Não foi possível enviar o código A instalação do Claude Code falhou A atualização do Claude Code falhou + O Claude Code parou antes de concluir o turno (%1$s) Prepara o ambiente Linux compartilhado e os agentes escolhidos. Agentes Agentes diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 06a4c61b..3e975dd9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -897,6 +897,7 @@ Не удалось отправить код Не удалось установить Claude Code Не удалось обновить Claude Code + Claude Code остановился, не завершив ход (%1$s) Подготовка общей среды Linux и выбранных агентов. Агенты Агенты diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 987027e1..b3b5dc7f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -897,6 +897,7 @@ 无法提交代码 Claude Code 安装失败 Claude Code 更新失败 + Claude Code 在完成本轮前退出(%1$s) 准备共享的 Linux 环境以及你选择的智能体。 智能体 智能体 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d07696da..628f999a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -943,6 +943,7 @@ Could not submit the code Claude Code installation failed Claude Code update failed + Claude Code stopped before finishing the turn (%1$s) Prepare the shared Linux environment and the agents you picked. Agents Agents diff --git a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt index 81dec7bc..d3d71551 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeActivityRepositoryTest.kt @@ -405,6 +405,121 @@ class RuntimeActivityRepositoryTest { assertTrue(completed.isEmpty()) } + @Test + fun `session idle without a resolvable session does not notify`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val completed = mutableListOf() + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + onSessionIdle = { sessionId, _, _ -> completed += sessionId }, + ) + advanceUntilIdle() + + target.eventFlow.emit(OpenCodeEvent.SessionIdle("unknown")) + advanceUntilIdle() + + assertTrue(completed.isEmpty()) + } + + @Test + fun `a session error mutes the completion callback for the trailing idle`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + target.sessions = listOf(OpenCodeSession(id = "ses_1", title = "Main")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val completed = mutableListOf() + val repository = + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + onSessionIdle = { sessionId, _, _ -> completed += sessionId }, + ) + advanceUntilIdle() + + target.eventFlow.emit(OpenCodeEvent.SessionError("ses_1", "boom")) + advanceUntilIdle() + target.eventFlow.emit(OpenCodeEvent.SessionIdle("ses_1")) + advanceUntilIdle() + + assertTrue(completed.isEmpty()) + assertTrue(repository.state.value.mutedSessionIds.isEmpty()) + } + + @Test + fun `a muted session notifies again once a new run starts`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + target.sessions = listOf(OpenCodeSession(id = "ses_1", title = "Main")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val completed = mutableListOf() + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + onSessionIdle = { sessionId, _, _ -> completed += sessionId }, + ) + advanceUntilIdle() + + target.eventFlow.emit(OpenCodeEvent.SessionError("ses_1", "boom")) + target.eventFlow.emit(OpenCodeEvent.SessionIdle("ses_1")) + advanceUntilIdle() + target.eventFlow.emit(OpenCodeEvent.SessionStatusChanged("ses_1", "busy")) + target.eventFlow.emit(OpenCodeEvent.SessionIdle("ses_1")) + advanceUntilIdle() + + assertEquals(listOf("ses_1"), completed) + } + + @Test + fun `markSessionAborted mutes the completion callback for the trailing idle`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + target.sessions = listOf(OpenCodeSession(id = "ses_1", title = "Main")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val completed = mutableListOf() + val repository = + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + onSessionIdle = { sessionId, _, _ -> completed += sessionId }, + ) + advanceUntilIdle() + + repository.markSessionRunning("ses_1") + repository.markSessionAborted("ses_1") + target.eventFlow.emit(OpenCodeEvent.SessionIdle("ses_1")) + advanceUntilIdle() + + assertTrue(completed.isEmpty()) + assertTrue(repository.state.value.activeSessionIds.isEmpty()) + } + @Test fun `late tool events after a session error do not resurrect activity`() = runTest { diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt index 81875806..e45350ec 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/ChatViewModelTest.kt @@ -657,6 +657,22 @@ class ChatViewModelTest { assertTrue(backend.permissionResponses.isEmpty()) } + @Test + fun `abort reports the session as aborted so its trailing idle does not notify`() = + runTest(dispatcher) { + val backend = FakeBackend() + val aborted = mutableListOf() + val viewModel = ChatViewModel(backend, onSessionAborted = { aborted += it }) + advanceUntilIdle() + viewModel.sendMessage("Do things") + advanceUntilIdle() + + viewModel.abort() + advanceUntilIdle() + + assertEquals(listOf("s1"), aborted) + } + @Test fun `history load maps tool parts alongside text parts`() = runTest(dispatcher) { diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt index f5be2052..0cb9c60d 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt @@ -4,6 +4,7 @@ import com.yugahashimoto.andcode.core.api.OpenCodeEvent import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -101,6 +102,16 @@ class ClaudeStreamJsonParserTest { assertTrue(parsed.events.single() is OpenCodeEvent.SessionIdle) } + @Test + fun `turnFinished tracks the live turn and resets on beginTurn`() { + val parser = parser() + parser.parse("""{"type":"result","subtype":"success","session_id":"abc"}""") + assertTrue(parser.turnFinished) + + parser.beginTurn() + assertFalse(parser.turnFinished) + } + @Test fun `surfaces a failed result as an error and then idles`() { val parsed = parser().parse("""{"type":"result","subtype":"error_during_execution","result":"boom"}""")