From 20d8cdf35c8097bd2c07d611e0ab34e75ca43c20 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:42:05 +0000 Subject: [PATCH 1/6] fix: keep parent chat running in drawer while a subagent works A session blocked on the task tool emits no events while its subagent runs. If the user navigated away, the chat reported the parent finished and the drawer settled it on the grey idle dot for the subagent's entire run. Learn each subagent's parent from session.created/session.updated events (falling back to the runtime API when the creation was missed) and forward the child's activity up the parent chain, so the drawer keeps showing the spinner. Ancestors the runtime already reported idle are left alone, so an experimental background subagent cannot resurrect a finished turn. --- .../andcode/core/api/OpenCodeApiModels.kt | 6 + .../andcode/core/api/OpenCodeEventParser.kt | 16 ++ .../repository/RuntimeActivityRepository.kt | 157 ++++++++++++++---- .../core/api/OpenCodeEventParserTest.kt | 23 +++ .../RuntimeActivityRepositoryTest.kt | 152 +++++++++++++++++ 5 files changed, 318 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt index 499a6ef7..819c899a 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeApiModels.kt @@ -317,6 +317,12 @@ sealed interface OpenCodeEvent { data class SessionIdle(val sessionId: String) : OpenCodeEvent + /** A new session appeared. Its [OpenCodeSession.parentId] names the session that spawned it. */ + data class SessionCreated(val session: OpenCodeSession) : 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 diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParser.kt b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParser.kt index 53010d10..a3dda0e3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParser.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParser.kt @@ -88,6 +88,22 @@ class OpenCodeEventParser( requestId = properties["requestID"]!!.jsonPrimitive.content, ) "session.idle" -> OpenCodeEvent.SessionIdle(properties["sessionID"]!!.jsonPrimitive.content) + "session.created" -> { + val session = + json.decodeFromJsonElement( + OpenCodeSession.serializer(), + properties["info"]!!.jsonObject, + ) + OpenCodeEvent.SessionCreated(session) + } + "session.updated" -> { + val session = + json.decodeFromJsonElement( + OpenCodeSession.serializer(), + properties["info"]!!.jsonObject, + ) + OpenCodeEvent.SessionUpdated(session) + } "session.status" -> OpenCodeEvent.SessionStatusChanged( sessionId = properties["sessionID"]!!.jsonPrimitive.content, 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 a3991861..7abcd547 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 @@ -81,6 +81,26 @@ class RuntimeActivityRepository( private val mutableResolvedPermissions = MutableSharedFlow(extraBufferCapacity = 64) val resolvedPermissions: SharedFlow = mutableResolvedPermissions.asSharedFlow() + /** + * Child session id to the session that spawned it, learned from `session.created` / + * `session.updated` events and resolved through the runtime API when the stream missed the + * creation (app restart mid-run, reconnected stream). A key present with a null value means + * the session is known to have no parent, so it is resolved only once. + * + * Events arrive on the stream collector while [markSessionRunning] arrives from the UI, so + * both maps are guarded by [parentLock]. + */ + private val parentIds = mutableMapOf() + private val parentLock = Any() + + /** + * Sessions the runtime itself reported as no longer running. Subagent events must not + * resurrect a parent whose own turn already ended (a background subagent outlives it), but + * must resurrect one that was only settled locally by [markSessionFinished] and is in fact + * still blocked on the subagent. + */ + private val runtimeIdleSessionIds = mutableSetOf() + init { scope.launch { registry.selected.collectLatest selected@{ target -> @@ -163,6 +183,7 @@ class RuntimeActivityRepository( */ fun markSessionRunning(sessionId: String) { if (sessionId.isBlank()) return + synchronized(parentLock) { runtimeIdleSessionIds.remove(sessionId) } mutableState.update { current -> current.copy( activeSessionIds = current.activeSessionIds + sessionId, @@ -204,35 +225,22 @@ class RuntimeActivityRepository( ) { when (event) { OpenCodeEvent.ServerConnected -> appendLog(messages.eventConnectedTitle, messages.eventConnectedDetail) + is OpenCodeEvent.SessionCreated -> rememberParent(event.session.id, event.session.parentId) + is OpenCodeEvent.SessionUpdated -> rememberParent(event.session.id, event.session.parentId) is OpenCodeEvent.MessagePartUpdated -> { val sessionId = event.part.sessionId ?: return - mutableState.update { current -> - if (sessionId in current.settledSessionIds || sessionId in current.completedSessionIds) { - current - } else { - current.copy(activeSessionIds = current.activeSessionIds + sessionId) - } - } + activateSession(target, sessionId) when (event.part.type) { "tool", "tool-invocation" -> appendLog(messages.eventTool, event.part.tool, sessionId) "reasoning" -> appendLog(messages.eventReasoning, null, sessionId) } } - is OpenCodeEvent.MessagePartDelta -> { - mutableState.update { current -> - if (event.sessionId in current.settledSessionIds || event.sessionId in current.completedSessionIds) { - current - } else { - current.copy(activeSessionIds = current.activeSessionIds + event.sessionId) - } - } - } + is OpenCodeEvent.MessagePartDelta -> activateSession(target, event.sessionId) is OpenCodeEvent.PermissionAsked -> { + // A live request proves the session is waiting, settled or not. + activateSession(target, event.request.sessionId, force = true) mutableState.update { current -> - current.copy( - permissions = current.permissions.filterNot { it.id == event.request.id } + event.request, - activeSessionIds = current.activeSessionIds + event.request.sessionId, - ) + current.copy(permissions = current.permissions.filterNot { it.id == event.request.id } + event.request) } appendLog(messages.eventPermission, event.request.permission, event.request.sessionId) onPermissionAsked?.invoke( @@ -242,6 +250,7 @@ class RuntimeActivityRepository( ) } is OpenCodeEvent.SessionIdle -> { + markRuntimeIdle(event.sessionId) mutableState.update { current -> current.copy( activeSessionIds = current.activeSessionIds - event.sessionId, @@ -250,22 +259,11 @@ class RuntimeActivityRepository( ) } appendLog(messages.eventCompleted, null, event.sessionId) - val isSubagent = - runCatching { target.session(event.sessionId).parentId != null } - .getOrDefault(false) - if (!isSubagent) { + if (parentIdOf(target, event.sessionId) == null) { onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId), target.id) } } - is OpenCodeEvent.MessageUpdated -> { - mutableState.update { current -> - if (event.info.sessionId in current.settledSessionIds || event.info.sessionId in current.completedSessionIds) { - current - } else { - current.copy(activeSessionIds = current.activeSessionIds + event.info.sessionId) - } - } - } + is OpenCodeEvent.MessageUpdated -> activateSession(target, event.info.sessionId) is OpenCodeEvent.PermissionReplied -> { mutableState.update { current -> current.copy(permissions = current.permissions.filterNot { it.id == event.requestId }) @@ -274,6 +272,11 @@ class RuntimeActivityRepository( onPermissionResolved?.invoke(event.requestId) } is OpenCodeEvent.SessionStatusChanged -> { + if (event.status == "idle") { + markRuntimeIdle(event.sessionId) + } else { + markRuntimeRunning(event.sessionId) + } mutableState.update { current -> current.copy( activeSessionIds = @@ -298,9 +301,13 @@ class RuntimeActivityRepository( }, ) } + if (event.status != "idle") { + activateAncestors(target, event.sessionId) + } } is OpenCodeEvent.SessionError -> { event.sessionId?.let { sessionId -> + markRuntimeIdle(sessionId) mutableState.update { current -> current.copy( activeSessionIds = current.activeSessionIds - sessionId, @@ -312,9 +319,7 @@ class RuntimeActivityRepository( onSessionError?.invoke(event.sessionId, event.message, target.id) } is OpenCodeEvent.QuestionAsked -> { - mutableState.update { current -> - current.copy(activeSessionIds = current.activeSessionIds + event.request.sessionId) - } + activateSession(target, event.request.sessionId, force = true) appendLog(messages.eventQuestion, event.request.questions.firstOrNull()?.question, event.request.sessionId) onQuestionAsked?.invoke( event.request, @@ -326,6 +331,86 @@ class RuntimeActivityRepository( } } + /** + * Marks a session active unless it was settled, and reports the same for every ancestor. + * + * A session blocked on the task tool emits nothing while its subagent works, so the runtime + * events of the child are the only signal that the parent's turn is still in flight. Without + * forwarding them, a parent the user navigated away from (which [markSessionFinished] then + * settles) sat on the grey idle dot in the drawer for the subagent's entire run. + */ + private suspend fun activateSession( + target: RuntimeTarget, + sessionId: String, + force: Boolean = false, + ) { + if (sessionId.isBlank()) return + mutableState.update { current -> + if (!force && (sessionId in current.settledSessionIds || sessionId in current.completedSessionIds)) { + current + } else { + current.copy(activeSessionIds = current.activeSessionIds + sessionId) + } + } + activateAncestors(target, sessionId) + } + + /** + * Walks up the parent chain keeping every ancestor marked running. An ancestor the runtime + * already reported idle ends the walk: its turn no longer waits on this subagent chain (only + * experimental background subagents outlive their parent's turn). + */ + private suspend fun activateAncestors( + target: RuntimeTarget, + sessionId: String, + ) { + var parentId = parentIdOf(target, sessionId) + while (parentId != null) { + if (isRuntimeIdle(parentId)) break + val ancestorId = parentId + mutableState.update { current -> + current.copy( + activeSessionIds = current.activeSessionIds + ancestorId, + completedSessionIds = current.completedSessionIds - ancestorId, + settledSessionIds = current.settledSessionIds - ancestorId, + ) + } + parentId = parentIdOf(target, ancestorId) + } + } + + private fun rememberParent( + sessionId: String, + parentId: String?, + ) { + if (sessionId.isBlank()) return + synchronized(parentLock) { parentIds[sessionId] = parentId } + } + + /** Parent of a subagent session, resolved once per session and cached. */ + private suspend fun parentIdOf( + target: RuntimeTarget, + sessionId: String, + ): String? { + synchronized(parentLock) { + if (sessionId in parentIds) return parentIds[sessionId] + } + // Misses the creation event when the stream reconnected mid-run; ask the runtime instead. + val parentId = runCatching { target.session(sessionId).parentId }.getOrNull() + synchronized(parentLock) { parentIds[sessionId] = parentId } + return parentId + } + + private fun markRuntimeIdle(sessionId: String) { + synchronized(parentLock) { runtimeIdleSessionIds.add(sessionId) } + } + + private fun markRuntimeRunning(sessionId: String) { + synchronized(parentLock) { runtimeIdleSessionIds.remove(sessionId) } + } + + private fun isRuntimeIdle(sessionId: String): Boolean = synchronized(parentLock) { sessionId in runtimeIdleSessionIds } + private suspend fun sessionTitle( target: RuntimeTarget, sessionId: String, diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParserTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParserTest.kt index bfc8bc85..90399122 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParserTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/core/api/OpenCodeEventParserTest.kt @@ -207,6 +207,29 @@ class OpenCodeEventParserTest { assertEquals("busy", event.status) } + @Test + fun `parses session created event with its parent link`() { + val event = + parser.parse( + """{"type":"session.created","properties":{"sessionID":"child1","info":{"id":"child1","slug":"happy-forest","parentID":"parent1","title":"Check dir (@general subagent)","time":{"created":1}}}}""", + ) as OpenCodeEvent.SessionCreated + + assertEquals("child1", event.session.id) + assertEquals("parent1", event.session.parentId) + assertEquals("Check dir (@general subagent)", event.session.title) + } + + @Test + fun `parses session updated event`() { + val event = + parser.parse( + """{"type":"session.updated","properties":{"sessionID":"parent1","info":{"id":"parent1","title":"Investigation","time":{"created":1,"updated":2}}}}""", + ) as OpenCodeEvent.SessionUpdated + + assertEquals("parent1", event.session.id) + assertEquals(null, event.session.parentId) + } + @Test fun `session error reports the readable message instead of raw json`() { val event = 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 1a79aa81..52d14e7a 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 @@ -436,6 +436,158 @@ class RuntimeActivityRepositoryTest { assertTrue(repository.state.value.activeSessionIds.isEmpty()) } + @Test + fun `subagent activity resurrects a parent settled by local navigation`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val repository = RuntimeActivityRepository(registry, TestScope(dispatcher)) + advanceUntilIdle() + + repository.markSessionRunning("ses_parent") + // The user opened another chat mid-turn; the parent is still blocked on the task tool. + repository.markSessionFinished("ses_parent", unread = true) + assertTrue(repository.state.value.activeSessionIds.isEmpty()) + + target.eventFlow.emit( + OpenCodeEvent.SessionCreated(OpenCodeSession(id = "child_1", parentId = "ses_parent")), + ) + target.eventFlow.emit( + OpenCodeEvent.MessagePartDelta( + sessionId = "child_1", + messageId = "msg_1", + partId = "part_1", + field = "text", + delta = "searching", + ), + ) + advanceUntilIdle() + + // The parent emits nothing while the subagent works, so the child's events must + // keep it marked running instead of leaving it on the idle/completed dot. + assertEquals(setOf("ses_parent", "child_1"), repository.state.value.activeSessionIds) + assertTrue("ses_parent" !in repository.state.value.completedSessionIds) + assertTrue("ses_parent" !in repository.state.value.settledSessionIds) + } + + @Test + fun `subagent events do not resurrect a parent the runtime reported idle`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val repository = RuntimeActivityRepository(registry, TestScope(dispatcher)) + advanceUntilIdle() + + target.eventFlow.emit( + OpenCodeEvent.SessionCreated(OpenCodeSession(id = "child_1", parentId = "ses_parent")), + ) + // A background subagent outlives its parent's turn; once the runtime declares the + // parent idle, the child's events must not wind the parent back up. + target.eventFlow.emit(OpenCodeEvent.SessionIdle("ses_parent")) + advanceUntilIdle() + target.eventFlow.emit( + OpenCodeEvent.MessagePartDelta( + sessionId = "child_1", + messageId = "msg_1", + partId = "part_1", + field = "text", + delta = "still working", + ), + ) + advanceUntilIdle() + + assertTrue("ses_parent" !in repository.state.value.activeSessionIds) + assertEquals(setOf("child_1"), repository.state.value.activeSessionIds) + } + + @Test + fun `subagent going idle keeps the parent running until its own idle`() = + 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() + val repository = + RuntimeActivityRepository( + registry = registry, + scope = TestScope(dispatcher), + onSessionIdle = { sessionId, _ -> completed += sessionId }, + ) + advanceUntilIdle() + + repository.markSessionRunning("ses_parent") + target.eventFlow.emit( + OpenCodeEvent.SessionCreated(OpenCodeSession(id = "child_1", parentId = "ses_parent")), + ) + target.eventFlow.emit(OpenCodeEvent.SessionStatusChanged("child_1", "busy")) + advanceUntilIdle() + + target.eventFlow.emit(OpenCodeEvent.SessionIdle("child_1")) + advanceUntilIdle() + + // The parent resumes its own loop after the task tool returns; only its own idle + // ends the turn. The subagent's idle also raises no completion notification. + assertEquals(setOf("ses_parent"), repository.state.value.activeSessionIds) + assertTrue(completed.isEmpty()) + + target.eventFlow.emit(OpenCodeEvent.SessionIdle("ses_parent")) + advanceUntilIdle() + + assertTrue(repository.state.value.activeSessionIds.isEmpty()) + assertEquals(listOf("ses_parent"), completed) + } + + @Test + fun `parent link falls back to the runtime when the creation event was missed`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val target = FakeTarget(requireConnected = false) + target.sessions = + listOf(OpenCodeSession(id = "child_1", parentId = "ses_parent", title = "Explore")) + val registry = + RuntimeRegistry( + store = FakeStore(selectedRuntimeId = target.id), + localTarget = target, + remoteFactory = { error("unused") }, + ) + val repository = RuntimeActivityRepository(registry, TestScope(dispatcher)) + advanceUntilIdle() + + repository.markSessionRunning("ses_parent") + repository.markSessionFinished("ses_parent", unread = false) + target.eventFlow.emit( + OpenCodeEvent.MessagePartUpdated( + OpenCodePart( + id = "part_1", + sessionId = "child_1", + messageId = "msg_1", + type = "text", + text = "searching", + ), + ), + ) + advanceUntilIdle() + + assertEquals(setOf("ses_parent", "child_1"), repository.state.value.activeSessionIds) + } + private class FakeUnreadStore( override var unreadSessionIds: Set, ) : UnreadSessionStore From 4a92129bae6725489a15dea5a83321c24e841855 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:20:58 +0000 Subject: [PATCH 2/6] fix: handle new session events in exhaustive when branches --- .../andcode/feature/assistant/AndCodeVoiceSession.kt | 2 ++ .../com/yugahashimoto/andcode/feature/chat/ChatViewModel.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt index 62f23981..4b365c5f 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/assistant/AndCodeVoiceSession.kt @@ -289,6 +289,8 @@ class AndCodeVoiceSession(context: Context) : // handling session.status too would finish the same turn twice. OpenCodeEvent.ServerConnected, is OpenCodeEvent.MessageUpdated, + is OpenCodeEvent.SessionCreated, + is OpenCodeEvent.SessionUpdated, is OpenCodeEvent.SessionStatusChanged, is OpenCodeEvent.QuestionAsked, is OpenCodeEvent.Unknown, 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 5e52c317..aea12a04 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 @@ -1534,6 +1534,8 @@ class ChatViewModel( ) } } + is OpenCodeEvent.SessionCreated -> Unit + is OpenCodeEvent.SessionUpdated -> Unit is OpenCodeEvent.Unknown -> Unit } } From b1fcc4f4d21fd97523a1873582343a7b00cedce4 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:56:40 +0000 Subject: [PATCH 3/6] fix: retry failed subagent parent lookups --- .../data/repository/RuntimeActivityRepository.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 7abcd547..50ef678a 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 @@ -387,7 +387,11 @@ class RuntimeActivityRepository( synchronized(parentLock) { parentIds[sessionId] = parentId } } - /** Parent of a subagent session, resolved once per session and cached. */ + /** + * Parent of a subagent session. A successful lookup is cached (a top-level session caches a + * null parent and is resolved only once); a failed lookup is not, so a transient error is + * retried on the next event instead of permanently disabling propagation for that session. + */ private suspend fun parentIdOf( target: RuntimeTarget, sessionId: String, @@ -396,9 +400,9 @@ class RuntimeActivityRepository( if (sessionId in parentIds) return parentIds[sessionId] } // Misses the creation event when the stream reconnected mid-run; ask the runtime instead. - val parentId = runCatching { target.session(sessionId).parentId }.getOrNull() - synchronized(parentLock) { parentIds[sessionId] = parentId } - return parentId + val session = runCatching { target.session(sessionId) }.getOrNull() ?: return null + synchronized(parentLock) { parentIds[sessionId] = session.parentId } + return session.parentId } private fun markRuntimeIdle(sessionId: String) { From 0e0b53b940f0d756b0008cf5ce6af4d8c2682aef Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:21:32 +0000 Subject: [PATCH 4/6] fix: distinguish unresolved session parents --- .../repository/RuntimeActivityRepository.kt | 20 +++++++++------ .../RuntimeActivityRepositoryTest.kt | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) 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 50ef678a..39a1fb5d 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 @@ -259,8 +259,10 @@ class RuntimeActivityRepository( ) } appendLog(messages.eventCompleted, null, event.sessionId) - if (parentIdOf(target, event.sessionId) == null) { - onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId), target.id) + parentResolutionOf(target, event.sessionId).onSuccess { parentId -> + if (parentId == null) { + onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId), target.id) + } } } is OpenCodeEvent.MessageUpdated -> activateSession(target, event.info.sessionId) @@ -395,14 +397,18 @@ class RuntimeActivityRepository( private suspend fun parentIdOf( target: RuntimeTarget, sessionId: String, - ): String? { + ): String? = parentResolutionOf(target, sessionId).getOrNull() + + private suspend fun parentResolutionOf( + target: RuntimeTarget, + sessionId: String, + ): Result { synchronized(parentLock) { - if (sessionId in parentIds) return parentIds[sessionId] + if (sessionId in parentIds) return Result.success(parentIds[sessionId]) } // Misses the creation event when the stream reconnected mid-run; ask the runtime instead. - val session = runCatching { target.session(sessionId) }.getOrNull() ?: return null - synchronized(parentLock) { parentIds[sessionId] = session.parentId } - return session.parentId + return runCatching { target.session(sessionId).parentId } + .onSuccess { parentId -> synchronized(parentLock) { parentIds[sessionId] = parentId } } } private fun markRuntimeIdle(sessionId: String) { 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 52d14e7a..fc6df7c7 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 @@ -588,6 +588,31 @@ class RuntimeActivityRepositoryTest { assertEquals(setOf("ses_parent", "child_1"), repository.state.value.activeSessionIds) } + @Test + fun `unresolved subagent parent does not raise a completion callback`() = + 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("child_1")) + advanceUntilIdle() + + assertTrue(completed.isEmpty()) + } + private class FakeUnreadStore( override var unreadSessionIds: Set, ) : UnreadSessionStore From 53a3caa00331ba7afde6f78f8759ffca5b880f9b Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:26:46 +0000 Subject: [PATCH 5/6] fix: update activity callback test signatures --- .../andcode/data/repository/RuntimeActivityRepositoryTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 fc6df7c7..e1630cf9 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 @@ -528,7 +528,7 @@ class RuntimeActivityRepositoryTest { RuntimeActivityRepository( registry = registry, scope = TestScope(dispatcher), - onSessionIdle = { sessionId, _ -> completed += sessionId }, + onSessionIdle = { sessionId, _, _ -> completed += sessionId }, ) advanceUntilIdle() @@ -603,7 +603,7 @@ class RuntimeActivityRepositoryTest { RuntimeActivityRepository( registry = registry, scope = TestScope(dispatcher), - onSessionIdle = { sessionId, _ -> completed += sessionId }, + onSessionIdle = { sessionId, _, _ -> completed += sessionId }, ) advanceUntilIdle() From 9a043169327375cb9438ae08ea2bd7a654fa9134 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:41:26 +0000 Subject: [PATCH 6/6] test: stabilize parent session activity assertions --- .../andcode/data/repository/RuntimeActivityRepositoryTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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 e1630cf9..81dec7bc 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 @@ -517,6 +517,8 @@ class RuntimeActivityRepositoryTest { runTest { val dispatcher = StandardTestDispatcher(testScheduler) val target = FakeTarget(requireConnected = false) + target.state.value = RuntimeState.Connected("1.0") + target.sessions = listOf(OpenCodeSession(id = "ses_parent", title = "Main")) val registry = RuntimeRegistry( store = FakeStore(selectedRuntimeId = target.id),