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..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 @@ -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,13 @@ class RuntimeActivityRepository( ) } appendLog(messages.eventCompleted, null, event.sessionId) - val isSubagent = - runCatching { target.session(event.sessionId).parentId != null } - .getOrDefault(false) - if (!isSubagent) { - 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) + 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) is OpenCodeEvent.PermissionReplied -> { mutableState.update { current -> current.copy(permissions = current.permissions.filterNot { it.id == event.requestId }) @@ -274,6 +274,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 +303,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 +321,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 +333,94 @@ 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. 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, + ): String? = parentResolutionOf(target, sessionId).getOrNull() + + private suspend fun parentResolutionOf( + target: RuntimeTarget, + sessionId: String, + ): Result { + synchronized(parentLock) { + if (sessionId in parentIds) return Result.success(parentIds[sessionId]) + } + // Misses the creation event when the stream reconnected mid-run; ask the runtime instead. + return runCatching { target.session(sessionId).parentId } + .onSuccess { parentId -> synchronized(parentLock) { parentIds[sessionId] = 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/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 } } 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..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 @@ -436,6 +436,185 @@ 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) + target.state.value = RuntimeState.Connected("1.0") + target.sessions = listOf(OpenCodeSession(id = "ses_parent", 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_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) + } + + @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