diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt index 7651ed0e..72fd785c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt @@ -173,6 +173,11 @@ interface OpenCodeApi { @Query("directory") directory: String? ): MessageWrapperDto + @GET("permission") + suspend fun listPermissions( + @Query("directory") directory: String? + ): List + @POST("permission/{requestId}/reply") suspend fun respondToPermission( @Path("requestId") requestId: String, @@ -180,6 +185,18 @@ interface OpenCodeApi { @Query("directory") directory: String? ): Boolean + @GET("api/session/{sessionId}/permission") + suspend fun listSessionPermissionsV2( + @Path("sessionId") sessionId: String + ): PermissionV2RequestListResponseDto + + @POST("api/session/{sessionId}/permission/{requestId}/reply") + suspend fun respondToPermissionV2( + @Path("sessionId") sessionId: String, + @Path("requestId") requestId: String, + @Body request: PermissionResponseRequest + ): Response + @POST("question/{requestId}/reply") suspend fun respondToQuestion( @Path("requestId") requestId: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt index 08c48165..c960397a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt @@ -43,5 +43,5 @@ internal class WorkspaceClientOfishAdapter( ) override suspend fun respondToPermission(id: String, request: PermissionResponseRequest): Boolean = - workspaceClient.respondToPermission(id, request) + workspaceClient.respondToPermissionLegacy(id, request) } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PermissionDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PermissionDtos.kt index 54f0eb5e..df7fceec 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PermissionDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PermissionDtos.kt @@ -25,6 +25,29 @@ data class PermissionToolDto( @SerialName("callID") val callID: String ) +@Serializable +data class PermissionV2RequestListResponseDto( + val data: List +) + +@Serializable +data class PermissionV2RequestDto( + val id: String, + @SerialName("sessionID") val sessionID: String, + val action: String, + val resources: List, + val save: List = emptyList(), + val metadata: JsonObject? = null, + val source: PermissionV2SourceDto? = null +) + +@Serializable +data class PermissionV2SourceDto( + val type: String, + @SerialName("messageID") val messageID: String, + @SerialName("callID") val callID: String +) + @Serializable data class PermissionResponseRequest( val reply: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt index 3a01d3bc..f4700458 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt @@ -557,6 +557,34 @@ object StatusMapper { ) } +// ============================================================================ +// Permission Mapper +// ============================================================================ + +object PermissionMapper { + fun mapToDomain(dto: PermissionDto): Permission = Permission( + id = dto.id, + type = dto.permission, + patterns = dto.patterns, + sessionID = dto.sessionID, + messageID = dto.tool?.messageID.orEmpty(), + callID = dto.tool?.callID, + metadata = dto.metadata, + always = dto.always, + ) + + fun mapV2ToDomain(dto: PermissionV2RequestDto): Permission = Permission( + id = dto.id, + type = dto.action, + patterns = dto.resources, + sessionID = dto.sessionID, + messageID = dto.source?.takeIf { it.type == "tool" }?.messageID.orEmpty(), + callID = dto.source?.takeIf { it.type == "tool" }?.callID, + metadata = dto.metadata ?: JsonObject(emptyMap()), + always = dto.save, + ) +} + // ============================================================================ // Event Mapper // ============================================================================ @@ -665,20 +693,13 @@ class EventMapper constructor( } "permission.asked" -> { val permissionDto = json.decodeFromJsonElement(dto.properties) - OpenCodeEvent.PermissionRequested( - Permission( - id = permissionDto.id, - type = permissionDto.permission, - patterns = permissionDto.patterns, - sessionID = permissionDto.sessionID, - messageID = permissionDto.tool?.messageID ?: "", - callID = permissionDto.tool?.callID, - metadata = permissionDto.metadata, - always = permissionDto.always - ) - ) + OpenCodeEvent.PermissionRequested(PermissionMapper.mapToDomain(permissionDto)) + } + "permission.v2.asked" -> { + val permissionDto = json.decodeFromJsonElement(dto.properties) + OpenCodeEvent.PermissionRequested(PermissionMapper.mapV2ToDomain(permissionDto)) } - "permission.replied" -> { + "permission.replied", "permission.v2.replied" -> { val props = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.PermissionReplied(props.sessionID, props.requestID, props.reply) } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt index 51acb5ba..55aa3d86 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt @@ -8,6 +8,7 @@ import dev.blazelight.p4oc.data.remote.dto.QuestionRequestDto import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest import dev.blazelight.p4oc.data.remote.dto.UpdateSessionRequest import dev.blazelight.p4oc.data.remote.mapper.MessageMapper +import dev.blazelight.p4oc.data.remote.mapper.PermissionMapper import dev.blazelight.p4oc.data.remote.mapper.SessionMapper import dev.blazelight.p4oc.data.remote.mapper.mapQuestionRequestDtoToDomain import dev.blazelight.p4oc.data.workspace.SessionWorkspaceClient @@ -40,6 +41,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.util.concurrent.atomic.AtomicInteger @@ -147,6 +149,7 @@ class SessionRepositoryImpl( override fun acceptEvent(event: OpenCodeEvent) { if (event is OpenCodeEvent.Connected) { hydrateAfterReconnect() + scope.launch { reconcileObservedPendingPermissions() } return } @@ -397,6 +400,7 @@ class SessionRepositoryImpl( if (hasRunningQuestion) { reconcilePendingQuestions() } + reconcilePendingPermissions(sessionId.value) } override fun sendMessageAsync(sessionId: SessionId, request: SendMessageRequest): Deferred> = scope.async { @@ -642,6 +646,34 @@ class SessionRepositoryImpl( updateSession(ownerSessionId, transform) } + private suspend fun reconcileObservedPendingPermissions() { + val sessionIds = synchronized(sessionUiStates) { sessionUiStates.keys.toList() } + sessionIds.forEach { sessionId -> reconcilePendingPermissions(sessionId) } + } + + private suspend fun reconcilePendingPermissions(sessionId: String) { + val legacyPermissions = runCatching { + client.listPermissions() + .filter { permission -> permission.sessionID == sessionId } + .map(PermissionMapper::mapToDomain) + }.getOrNull() + val permissions = if (!legacyPermissions.isNullOrEmpty()) { + legacyPermissions + } else { + runCatching { client.listSessionPermissionsV2(sessionId).map(PermissionMapper::mapV2ToDomain) } + .getOrNull() + ?: legacyPermissions + ?: return + } + updateSession(sessionId) { state -> + val recovered = permissions.mapNotNull { permission -> + val callId = permission.callID ?: return@mapNotNull null + callId to permission + }.toMap() + state.copy(pendingPermissionsByCallId = recovered) + } + } + private fun mergeLoadedMessages(sessionId: String, loaded: List) { val state = messageState(sessionId) state.update { current -> diff --git a/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt index 3a678740..6e004de3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt @@ -1,6 +1,8 @@ package dev.blazelight.p4oc.data.workspace import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest +import dev.blazelight.p4oc.data.remote.dto.PermissionDto +import dev.blazelight.p4oc.data.remote.dto.PermissionV2RequestDto import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest import dev.blazelight.p4oc.data.remote.dto.SessionDto @@ -40,5 +42,9 @@ interface SessionWorkspaceClient { suspend fun sendMessageAsync(sessionId: String, request: SendMessageRequest) + suspend fun listSessionPermissionsV2(sessionId: String): List = emptyList() + + suspend fun listPermissions(): List = emptyList() + suspend fun abortSession(id: String): Boolean } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt index 5c84c06d..fe4fcb27 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt @@ -11,7 +11,9 @@ import dev.blazelight.p4oc.data.remote.dto.FileStatusDto import dev.blazelight.p4oc.data.remote.dto.ForkSessionRequest import dev.blazelight.p4oc.data.remote.dto.InitSessionRequest import dev.blazelight.p4oc.data.remote.dto.MessageWrapperDto +import dev.blazelight.p4oc.data.remote.dto.PermissionDto import dev.blazelight.p4oc.data.remote.dto.PermissionResponseRequest +import dev.blazelight.p4oc.data.remote.dto.PermissionV2RequestDto import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.dto.QuestionReplyRequest import dev.blazelight.p4oc.data.remote.dto.QuestionRequestDto @@ -27,6 +29,7 @@ import dev.blazelight.p4oc.data.remote.dto.VcsInfoDto import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.workspace.Workspace +import retrofit2.HttpException import java.io.IOException class WorkspaceClient( @@ -101,7 +104,25 @@ class WorkspaceClient( api.sendMessageAsync(sessionId, request, directory) } - suspend fun respondToPermission(requestId: String, request: PermissionResponseRequest): Boolean = + override suspend fun listSessionPermissionsV2(sessionId: String): List = + api.listSessionPermissionsV2(sessionId).data + + override suspend fun listPermissions(): List = api.listPermissions(directory) + + suspend fun respondToPermission( + sessionId: String, + requestId: String, + request: PermissionResponseRequest + ): Boolean { + val response = api.respondToPermissionV2(sessionId, requestId, request) + val contentType = response.headers()["Content-Type"].orEmpty() + if (response.isSuccessful && !contentType.startsWith("text/html")) return true + if (response.code() != 404 && !contentType.startsWith("text/html")) throw HttpException(response) + + return respondToPermissionLegacy(requestId, request) + } + + suspend fun respondToPermissionLegacy(requestId: String, request: PermissionResponseRequest): Boolean = api.respondToPermission(requestId, request, directory) suspend fun respondToQuestion(requestId: String, request: QuestionReplyRequest): Boolean = diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt index 87c01f51..a83905ba 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt @@ -224,7 +224,7 @@ private fun AssistantMessageContent( ToolGroupWidget( tools = group.tools, defaultState = defaultToolWidgetState, - pendingPermissionCallIds = pendingPermissionsByCallId.keys, + pendingPermissionIdsByCallId = pendingPermissionsByCallId.mapValues { it.value.id }, onToolApprove = onToolApprove, onToolDeny = onToolDeny, onOpenSubSession = onOpenSubSession diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt index cff98984..6e5d651f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt @@ -38,6 +38,7 @@ fun BashWidgetExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, + approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, modifier: Modifier = Modifier @@ -116,10 +117,10 @@ fun BashWidgetExpanded( } // Pending approval buttons - if (state is ToolState.Pending && showApprovalActions) { + if (showApprovalActions) { PendingApprovalButtons( - onApprove = { onToolApprove(tool.callID) }, - onDeny = { onToolDeny(tool.callID) } + onApprove = { onToolApprove(approvalRequestId) }, + onDeny = { onToolDeny(approvalRequestId) } ) } } @@ -342,6 +343,7 @@ fun DefaultWidgetExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, + approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, modifier: Modifier = Modifier @@ -429,10 +431,10 @@ fun DefaultWidgetExpanded( } // Pending approval buttons - if (state is ToolState.Pending && showApprovalActions) { + if (showApprovalActions) { PendingApprovalButtons( - onApprove = { onToolApprove(tool.callID) }, - onDeny = { onToolDeny(tool.callID) } + onApprove = { onToolApprove(approvalRequestId) }, + onDeny = { onToolDeny(approvalRequestId) } ) } } @@ -447,6 +449,7 @@ fun TaskWidgetExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, + approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)?, @@ -565,10 +568,10 @@ fun TaskWidgetExpanded( } // Pending approval buttons - if (state is ToolState.Pending && showApprovalActions) { + if (showApprovalActions) { PendingApprovalButtons( - onApprove = { onToolApprove(tool.callID) }, - onDeny = { onToolDeny(tool.callID) } + onApprove = { onToolApprove(approvalRequestId) }, + onDeny = { onToolDeny(approvalRequestId) } ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt index 4fa4a2e1..6e56bc3d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt @@ -206,6 +206,7 @@ fun ToolCallExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean = true, + approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, @@ -216,6 +217,7 @@ fun ToolCallExpanded( tool = tool, onClick = onClick, showApprovalActions = showApprovalActions, + approvalRequestId = approvalRequestId, onToolApprove = onToolApprove, onToolDeny = onToolDeny, modifier = modifier @@ -234,6 +236,7 @@ fun ToolCallExpanded( tool = tool, onClick = onClick, showApprovalActions = showApprovalActions, + approvalRequestId = approvalRequestId, onToolApprove = onToolApprove, onToolDeny = onToolDeny, onOpenSubSession = onOpenSubSession, @@ -243,6 +246,7 @@ fun ToolCallExpanded( tool = tool, onClick = onClick, showApprovalActions = showApprovalActions, + approvalRequestId = approvalRequestId, onToolApprove = onToolApprove, onToolDeny = onToolDeny, modifier = modifier diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt index c56f7b87..17813411 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt @@ -60,7 +60,7 @@ private data class ToolGroup( fun ToolGroupWidget( tools: List, defaultState: ToolWidgetState, - pendingPermissionCallIds: Set = emptySet(), + pendingPermissionIdsByCallId: Map = emptyMap(), onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, @@ -68,8 +68,8 @@ fun ToolGroupWidget( ) { val theme = LocalOpenCodeTheme.current - // HITL tools (pending state) always show expanded - val hasPendingTools = tools.any { it.state is ToolState.Pending } + // A recovered permission request is authoritative even if persisted tool history still says running. + val hasPendingTools = tools.any { it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys } val effectiveDefault = if (hasPendingTools) ToolWidgetState.EXPANDED else defaultState var currentState by remember(tools.firstOrNull()?.callID) { mutableStateOf(effectiveDefault) } @@ -82,12 +82,12 @@ fun ToolGroupWidget( } // Group tools by name and determine aggregate state - val toolGroups = remember(tools) { + val toolGroups = remember(tools, pendingPermissionIdsByCallId.keys) { tools.groupBy { it.toolName } .map { (name, toolList) -> val state = when { + toolList.any { it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys } -> AggregateToolState.PENDING toolList.any { it.state is ToolState.Running } -> AggregateToolState.RUNNING - toolList.any { it.state is ToolState.Pending } -> AggregateToolState.PENDING toolList.any { it.state is ToolState.Error } -> AggregateToolState.ERROR else -> AggregateToolState.COMPLETED } @@ -188,11 +188,11 @@ fun ToolGroupWidget( modifier = Modifier.fillMaxWidth() ) - // Show approval buttons if pending - if (tool.state is ToolState.Pending && tool.callID in pendingPermissionCallIds) { + // Show approval buttons for live or recovered pending permissions. + if (tool.callID in pendingPermissionIdsByCallId.keys) { PendingApprovalButtonsInline( - onApprove = { onToolApprove(tool.callID) }, - onDeny = { onToolDeny(tool.callID) } + onApprove = { onToolApprove(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) }, + onDeny = { onToolDeny(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) } ) } } @@ -201,7 +201,8 @@ fun ToolGroupWidget( ToolCallExpanded( tool = tool, onClick = { currentState = currentState.next() }, - showApprovalActions = tool.callID in pendingPermissionCallIds, + showApprovalActions = tool.callID in pendingPermissionIdsByCallId.keys, + approvalRequestId = pendingPermissionIdsByCallId[tool.callID] ?: tool.callID, onToolApprove = onToolApprove, onToolDeny = onToolDeny, onOpenSubSession = onOpenSubSession, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index f6e900a1..243d1a4c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -458,7 +458,7 @@ class ChatViewModel constructor( fun respondToPermission(permissionId: String, response: String) { viewModelScope.launch { val request = PermissionResponseRequest(reply = response) - when (val result = safeApiCall { workspaceClient.respondToPermission(permissionId, request) }) { + when (val result = safeApiCall { workspaceClient.respondToPermission(sessionId, permissionId, request) }) { is ApiResult.Success -> { dialogManager.clearPermission(permissionId) sessionRepository.clearPermission(SessionId(sessionId), permissionId) diff --git a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt index 9957fb13..7c75d88c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt @@ -154,6 +154,62 @@ class EventMapperTest { assertEquals(listOf("once"), perm.always) } + @Test + fun `maps permission_v2_asked with source callID`() { + val properties = buildJsonObject { + put("id", "per_1") + put("sessionID", "sess-1") + put("action", "bash") + putJsonArray("resources") { + add(JsonPrimitive("npm test")) + } + putJsonArray("save") { + add(JsonPrimitive("npm test")) + } + putJsonObject("metadata") { + put("key", "value") + } + putJsonObject("source") { + put("type", "tool") + put("messageID", "msg-42") + put("callID", "call-99") + } + } + val dto = EventDataDto(type = "permission.v2.asked", properties = properties) + + val event = eventMapper.mapToEvent(dto) + + assertNotNull(event) + assertTrue(event is OpenCodeEvent.PermissionRequested) + val perm = (event as OpenCodeEvent.PermissionRequested).permission + assertEquals("per_1", perm.id) + assertEquals("bash", perm.type) + assertEquals(listOf("npm test"), perm.patterns) + assertEquals("sess-1", perm.sessionID) + assertEquals("msg-42", perm.messageID) + assertEquals("call-99", perm.callID) + assertEquals(listOf("npm test"), perm.always) + } + + @Test + fun `maps permission_v2_replied`() { + val properties = buildJsonObject { + put("sessionID", "sess-1") + put("requestID", "per_1") + put("reply", "once") + } + val dto = EventDataDto(type = "permission.v2.replied", properties = properties) + + val event = eventMapper.mapToEvent(dto) + + assertNotNull(event) + assertTrue(event is OpenCodeEvent.PermissionReplied) + val reply = event as OpenCodeEvent.PermissionReplied + assertEquals("sess-1", reply.sessionID) + assertEquals("per_1", reply.requestID) + assertEquals("once", reply.reply) + } + // ── session.status ────────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt index a4d18588..c0821572 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt @@ -299,6 +299,84 @@ class SessionRepositoryImplTest { assertTrue(sessions.containsKey("streamed")) } + @Test + fun `connected event recovers missed pending permissions for observed sessions`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = emptyList() + permissionsBySession = mapOf( + "s1" to listOf(FakeWorkspaceClient.permissionV2Dto(id = "per_1", sessionId = "s1", callId = "call-1")) + ) + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + repository.sessionUiState(SessionId("s1")) + + repository.acceptEvent(OpenCodeEvent.Connected) + advanceUntilIdle() + + val permissions = repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId + assertEquals(1, client.listSessionPermissionsV2Calls) + assertEquals("per_1", permissions.getValue("call-1").id) + } + + @Test + fun `connected event recovers missed legacy pending permissions before probing v2`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = emptyList() + listSessionPermissionsV2Failure = RuntimeException("not found") + legacyPermissions = listOf( + FakeWorkspaceClient.permissionDto(id = "per_1", sessionId = "s1", callId = "call-1"), + FakeWorkspaceClient.permissionDto(id = "per_2", sessionId = "other", callId = "call-2"), + ) + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + repository.sessionUiState(SessionId("s1")) + + repository.acceptEvent(OpenCodeEvent.Connected) + advanceUntilIdle() + + val permissions = repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId + assertEquals(0, client.listSessionPermissionsV2Calls) + assertEquals(1, client.listPermissionsCalls) + assertEquals("per_1", permissions.getValue("call-1").id) + assertFalse(permissions.containsKey("call-2")) + } + + @Test + fun `permission reconciliation clears stale resolved permissions`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = emptyList() + permissionsBySession = mapOf( + "s1" to listOf(FakeWorkspaceClient.permissionV2Dto(id = "per_1", sessionId = "s1", callId = "call-1")) + ) + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + repository.sessionUiState(SessionId("s1")) + repository.acceptEvent(OpenCodeEvent.Connected) + advanceUntilIdle() + assertTrue(repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId.isNotEmpty()) + + client.permissionsBySession = emptyMap() + repository.acceptEvent(OpenCodeEvent.Connected) + advanceUntilIdle() + + assertTrue(repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId.isEmpty()) + } + @Test fun `delete http failure refetches server truth instead of rollback map`() = runTest { val client = FakeWorkspaceClient().apply { diff --git a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt index a2e0ad7b..8a3a4729 100644 --- a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt +++ b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt @@ -1,6 +1,10 @@ package dev.blazelight.p4oc.fakes import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest +import dev.blazelight.p4oc.data.remote.dto.PermissionDto +import dev.blazelight.p4oc.data.remote.dto.PermissionToolDto +import dev.blazelight.p4oc.data.remote.dto.PermissionV2RequestDto +import dev.blazelight.p4oc.data.remote.dto.PermissionV2SourceDto import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.dto.ProjectTimeDto import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest @@ -35,6 +39,10 @@ class FakeWorkspaceClient( private set var abortSessionCalls: Int = 0 private set + var listSessionPermissionsV2Calls: Int = 0 + private set + var listPermissionsCalls: Int = 0 + private set val listSessionsDirectories = mutableListOf() val listSessionsScopes = mutableListOf() @@ -44,6 +52,9 @@ class FakeWorkspaceClient( var listSessionsResult: List = emptyList() var sessionsByDirectory: Map>? = null var statusesByDirectory: Map> = emptyMap() + var permissionsBySession: Map> = emptyMap() + var legacyPermissions: List = emptyList() + var listSessionPermissionsV2Failure: Throwable? = null var listSessionsFailure: Throwable? = null var getSessionResults: MutableMap = mutableMapOf() var getSessionFailure: Throwable? = null @@ -141,6 +152,17 @@ class FakeWorkspaceClient( sendMessageBlocker?.await() } + override suspend fun listSessionPermissionsV2(sessionId: String): List { + listSessionPermissionsV2Calls += 1 + listSessionPermissionsV2Failure?.let { throw it } + return permissionsBySession[sessionId].orEmpty() + } + + override suspend fun listPermissions(): List { + listPermissionsCalls += 1 + return legacyPermissions + } + override suspend fun abortSession(id: String): Boolean { abortSessionCalls += 1 abortSessionBlocker?.await() @@ -159,6 +181,37 @@ class FakeWorkspaceClient( time = ProjectTimeDto(created = 1L, initialized = 2L), ) + fun permissionV2Dto( + id: String, + sessionId: String, + callId: String, + action: String = "bash", + resource: String = "npm test", + ): PermissionV2RequestDto = PermissionV2RequestDto( + id = id, + sessionID = sessionId, + action = action, + resources = listOf(resource), + save = listOf(resource), + source = PermissionV2SourceDto(type = "tool", messageID = "msg-$callId", callID = callId), + ) + + fun permissionDto( + id: String, + sessionId: String, + callId: String, + permission: String = "bash", + pattern: String = "npm test", + ): PermissionDto = PermissionDto( + id = id, + permission = permission, + patterns = listOf(pattern), + sessionID = sessionId, + metadata = kotlinx.serialization.json.JsonObject(emptyMap()), + always = listOf(pattern), + tool = PermissionToolDto(messageID = "msg-$callId", callID = callId), + ) + fun sessionDto( id: String, title: String = id,