diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 40339755491e..b7fae5fd81f7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -7,7 +7,7 @@ import ai.openclaw.app.chat.ChatComposerOwner import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatPendingToolCall -import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatPlanSnapshot import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatSwarmGroup @@ -676,7 +676,8 @@ class MainViewModel private constructor( val chatSubagentActivities: StateFlow> = runtimeState(initial = emptyMap()) { it.chatSubagentActivities } val chatQuestions: StateFlow> = runtimeState(initial = emptyList()) { it.chatQuestions } - val chatPlanSteps: StateFlow> = runtimeState(initial = emptyList()) { it.chatPlanSteps } + val chatPlanSnapshot: StateFlow = + runtimeState(initial = ChatPlanSnapshot(steps = emptyList())) { it.chatPlanSnapshot } val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } val chatSwarmGroups: StateFlow> = runtimeState(initial = emptyList()) { it.chatSwarmGroups } val chatSessionBranches: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessionBranches } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 638cab984b9d..84dceee9ac46 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -11,7 +11,7 @@ import ai.openclaw.app.chat.ChatController import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatPendingToolCall -import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatPlanSnapshot import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionDeletion import ai.openclaw.app.chat.ChatSessionEntry @@ -2992,7 +2992,7 @@ class NodeRuntime private constructor( val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls val chatSubagentActivities: StateFlow> = chat.subagentActivities val chatQuestions: StateFlow> = chat.questions - val chatPlanSteps: StateFlow> = chat.planSteps + val chatPlanSnapshot: StateFlow = chat.planSnapshot val chatSessions: StateFlow> = chat.sessions val chatSwarmGroups: StateFlow> = chat.swarmGroups val chatSessionBranches: StateFlow> = chat.sessionBranches diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 6de3e5f70cc4..80c7e7b8e438 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -354,8 +354,8 @@ class ChatController internal constructor( private val questionEvictionJobs = mutableMapOf() - private val _planSteps = MutableStateFlow>(emptyList()) - val planSteps: StateFlow> = _planSteps.asStateFlow() + private val _planSnapshot = MutableStateFlow(ChatPlanSnapshot(steps = emptyList())) + val planSnapshot: StateFlow = _planSnapshot.asStateFlow() // Owning run for the current plan snapshot; run-scoped terminal events must // not clear another run's checklist (parallel/delayed runs share a session). @@ -810,7 +810,7 @@ class ChatController internal constructor( clearPendingRuns() clearLiveRunUi() } - clearPlanSteps() + clearPlan() appliedMainSessionKey = "main" beginHistoryLoad( key = "main", @@ -2389,7 +2389,7 @@ class ChatController internal constructor( clearLiveHistoryMarker() clearPendingRuns() clearLiveRunUi() - clearPlanSteps() + clearPlan() _sessionId.value = null _historyLoading.value = markLoading if (clearMessages) { @@ -2710,7 +2710,7 @@ class ChatController internal constructor( if (ack.isTerminalSuccess) { if (isCapturedOwnerCurrent()) { clearLiveRunUi() - clearPlanSteps() + clearPlan() refreshCurrentHistoryBestEffort(runIdsToReconcile = setOf(actualRunId)) } true @@ -2719,7 +2719,7 @@ class ChatController internal constructor( // Surface failed acceptance instead of letting a cleared composer look successful. if (isCapturedOwnerCurrent()) { clearLiveRunUi() - clearPlanSteps() + clearPlan() updateLocalizedErrorText(nativeText("Chat failed before the run started; try again.")) } // The parked row owns the input; restoring the draft would duplicate it. @@ -2978,7 +2978,7 @@ class ChatController internal constructor( _streamingAssistantText.value = null pendingToolCallsById.clear() publishPendingToolCalls() - clearPlanSteps() + clearPlan() publishRunPresentation() } @@ -5545,7 +5545,7 @@ class ChatController internal constructor( synchronized(pendingRuns) { pendingRuns.isNotEmpty() } || unresolvedRepliesByRunId.isNotEmpty() if (!hasNewerRun) { clearLiveRunUi() - clearPlanStepsFor(runId) + clearPlanFor(runId) updateLocalizedErrorText( if (state == "error") { payload["errorMessage"].asStringOrNull()?.let(::verbatimText) ?: nativeText("Chat failed") @@ -5583,7 +5583,7 @@ class ChatController internal constructor( clearPendingRuns(clearOptimisticMessages = false, clearRunTelemetry = false) } clearLiveRunUi() - clearPlanStepsFor(runId) + clearPlanFor(runId) refreshCurrentHistoryBestEffort( runIdsToReconcile = terminalRunIds, updateSessionInfo = true, @@ -5751,7 +5751,7 @@ class ChatController internal constructor( if (!entry.hasActiveRunMetadata) retireRunTelemetry(settledRunId) if (terminalWasLocal) { clearPendingRun(settledRunId) - clearPlanStepsFor(settledRunId) + clearPlanFor(settledRunId) clearTransientRunUiIfIdle() } else { publishRunPresentation() @@ -5824,7 +5824,7 @@ class ChatController internal constructor( if (!accepted) return if (isLocallyOwnedRun(lifecycleRunId)) { clearPendingRun(lifecycleRunId) - clearPlanStepsFor(lifecycleRunId) + clearPlanFor(lifecycleRunId) clearTransientRunUiIfIdle() } else { publishRunPresentation() @@ -5884,9 +5884,19 @@ class ChatController internal constructor( } "plan" -> { if (runId.isNullOrBlank()) return - if (data?.get("phase").asStringOrNull() != "update") return + val planData = data ?: return + if (planData["phase"].asStringOrNull() != "update") return planRunId = runId - _planSteps.value = parseChatPlanSteps(data?.get("steps")) + val steps = parseChatPlanSteps(planData["steps"]) + _planSnapshot.value = + ChatPlanSnapshot( + steps = steps, + explanation = + planData["explanation"] + .asStringOrNull() + ?.trim() + ?.takeIf { steps.isNotEmpty() && it.isNotEmpty() }, + ) } "error" -> { updateLocalizedErrorText(nativeText("Event stream interrupted; try refreshing.")) @@ -5894,7 +5904,7 @@ class ChatController internal constructor( clearPendingRuns() } else { clearPendingRun(runId) - clearPlanStepsFor(runId) + clearPlanFor(runId) clearTransientRunUiIfIdle() } pendingToolCallsById.clear() @@ -6075,14 +6085,14 @@ class ChatController internal constructor( _streamingAssistantText.value = null } - private fun clearPlanSteps() { + private fun clearPlan() { planRunId = null - _planSteps.value = emptyList() + _planSnapshot.value = ChatPlanSnapshot(steps = emptyList()) } - private fun clearPlanStepsFor(runId: String?) { + private fun clearPlanFor(runId: String?) { if (runId == null || planRunId == null || planRunId == runId) { - clearPlanSteps() + clearPlan() } } @@ -6107,7 +6117,7 @@ class ChatController internal constructor( history.sessionInfo?.hasActiveRun == false || (activeRunIds != null && retainedRunId !in activeRunIds) ) { - clearPlanSteps() + clearPlan() } return } @@ -6124,12 +6134,12 @@ class ChatController internal constructor( } val plan = run.plan if (plan == null) { - if (planRunId != null && planRunId != runId) clearPlanSteps() + if (planRunId != null && planRunId != runId) clearPlan() } else if (plan.steps.isEmpty()) { - clearPlanSteps() + clearPlan() } else { planRunId = runId - _planSteps.value = plan.steps + _planSnapshot.value = plan } } @@ -6224,7 +6234,7 @@ class ChatController internal constructor( private fun clearTransientRunUiIfIdle(preservePlan: Boolean = false) { if (synchronized(pendingRuns) { pendingRuns.isNotEmpty() }) return clearLiveRunUi() - if (!preservePlan) clearPlanSteps() + if (!preservePlan) clearPlan() } private fun clearPendingRuns( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 1710187144bc..f35102f09aa1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -17,7 +17,7 @@ import ai.openclaw.app.chat.ChatMessageContent import ai.openclaw.app.chat.ChatOutboxItem import ai.openclaw.app.chat.ChatOutboxStatus import ai.openclaw.app.chat.ChatPendingToolCall -import ai.openclaw.app.chat.ChatPlanStep +import ai.openclaw.app.chat.ChatPlanSnapshot import ai.openclaw.app.chat.ChatPlanStepStatus import ai.openclaw.app.chat.ChatQuestionPrompt import ai.openclaw.app.chat.ChatSessionEntry @@ -303,7 +303,7 @@ fun ChatScreen( val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val subagentActivities by viewModel.chatSubagentActivities.collectAsState() val questions by viewModel.chatQuestions.collectAsState() - val planSteps by viewModel.chatPlanSteps.collectAsState() + val planSnapshot by viewModel.chatPlanSnapshot.collectAsState() val sessions by viewModel.chatSessions.collectAsState() val swarmGroups by viewModel.chatSwarmGroups.collectAsState() val sessionBranches by viewModel.chatSessionBranches.collectAsState() @@ -788,8 +788,8 @@ fun ChatScreen( modifier = Modifier.weight(1f), ) - if (pendingRunCount > 0 && planSteps.isNotEmpty()) { - PlanChecklistPill(steps = planSteps) + if (pendingRunCount > 0 && planSnapshot.steps.isNotEmpty()) { + PlanChecklistPill(plan = planSnapshot) } ChatSwarmProgress(groups = swarmGroups) @@ -2107,8 +2107,9 @@ private fun ChatNotice( } @Composable -private fun PlanChecklistPill(steps: List) { +private fun PlanChecklistPill(plan: ChatPlanSnapshot) { var expanded by rememberSaveable { mutableStateOf(false) } + val steps = plan.steps val currentStep = steps.firstOrNull { it.status == ChatPlanStepStatus.InProgress } ?: steps.lastOrNull { it.status == ChatPlanStepStatus.Completed } @@ -2130,7 +2131,7 @@ private fun PlanChecklistPill(steps: List) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.primary, CircleShape)) + PlanStepMarker(status = currentStep.status) Text( text = currentStep.step, style = ClawTheme.type.caption, @@ -2156,6 +2157,14 @@ private fun PlanChecklistPill(steps: List) { if (expanded) { HorizontalDivider(color = ClawTheme.colors.border) Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + plan.explanation?.let { explanation -> + Text( + text = explanation, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + modifier = Modifier.padding(start = 22.dp), + ) + } steps.forEach { step -> val textColor = when (step.status) { @@ -2172,20 +2181,7 @@ private fun PlanChecklistPill(steps: List) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Box(modifier = Modifier.width(14.dp), contentAlignment = Alignment.Center) { - when (step.status) { - ChatPlanStepStatus.Completed -> - Text( - text = "✓", - style = ClawTheme.type.caption.copy(fontWeight = FontWeight.Bold), - color = ClawTheme.colors.success, - ) - ChatPlanStepStatus.InProgress -> - Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.primary, CircleShape)) - ChatPlanStepStatus.Pending -> - Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.textSubtle, CircleShape)) - } - } + PlanStepMarker(status = step.status) Text( text = step.step, style = textStyle, @@ -2199,6 +2195,24 @@ private fun PlanChecklistPill(steps: List) { } } +@Composable +private fun PlanStepMarker(status: ChatPlanStepStatus) { + Box(modifier = Modifier.width(14.dp), contentAlignment = Alignment.Center) { + when (status) { + ChatPlanStepStatus.Completed -> + Text( + text = "✓", + style = ClawTheme.type.caption.copy(fontWeight = FontWeight.Bold), + color = ClawTheme.colors.success, + ) + ChatPlanStepStatus.InProgress -> + Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.primary, CircleShape)) + ChatPlanStepStatus.Pending -> + Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.textSubtle, CircleShape)) + } + } +} + @Composable private fun ChatComposer( value: String, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt index 157e50016f84..5a0b0e87e42a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerPlanStreamTest.kt @@ -39,17 +39,21 @@ class ChatControllerPlanStreamTest { "agent", planPayload( runId, - """{"phase":"update","steps":[{"step":" Inspect ","status":"completed"},{"step":"Patch","status":"in_progress"},{"step":"Test","status":"pending"},{"step":" ","status":"pending"},{"step":"Unknown","status":"blocked"},{"step":42,"status":"pending"},42]}""", + """{"phase":"update","explanation":" Inspect, patch, and test ","steps":[{"step":" Inspect ","status":"completed"},{"step":"Patch","status":"in_progress"},{"step":"Test","status":"pending"},{"step":" ","status":"pending"},{"step":"Unknown","status":"blocked"},{"step":42,"status":"pending"},42]}""", ), ) assertEquals( - listOf( - ChatPlanStep(step = "Inspect", status = ChatPlanStepStatus.Completed), - ChatPlanStep(step = "Patch", status = ChatPlanStepStatus.InProgress), - ChatPlanStep(step = "Test", status = ChatPlanStepStatus.Pending), + ChatPlanSnapshot( + steps = + listOf( + ChatPlanStep(step = "Inspect", status = ChatPlanStepStatus.Completed), + ChatPlanStep(step = "Patch", status = ChatPlanStepStatus.InProgress), + ChatPlanStep(step = "Test", status = ChatPlanStepStatus.Pending), + ), + explanation = "Inspect, patch, and test", ), - controller.planSteps.value, + controller.planSnapshot.value, ) } @@ -64,11 +68,14 @@ class ChatControllerPlanStreamTest { ) assertEquals( - listOf( - ChatPlanStep(step = "First", status = ChatPlanStepStatus.Pending), - ChatPlanStep(step = "Second", status = ChatPlanStepStatus.Pending), + ChatPlanSnapshot( + steps = + listOf( + ChatPlanStep(step = "First", status = ChatPlanStepStatus.Pending), + ChatPlanStep(step = "Second", status = ChatPlanStepStatus.Pending), + ), ), - controller.planSteps.value, + controller.planSnapshot.value, ) } @@ -79,16 +86,19 @@ class ChatControllerPlanStreamTest { controller.handleGatewayEvent( "agent", - planPayload(runId, """{"phase":"update","steps":[{"step":"First","status":"in_progress"},{"step":"Second","status":"pending"}]}"""), + planPayload(runId, """{"phase":"update","explanation":"Initial approach","steps":[{"step":"First","status":"in_progress"},{"step":"Second","status":"pending"}]}"""), ) controller.handleGatewayEvent( "agent", - planPayload(runId, """{"phase":"update","steps":[{"step":"Replacement","status":"completed"}]}"""), + planPayload(runId, """{"phase":"update","explanation":"Replacement approach","steps":[{"step":"Replacement","status":"completed"}]}"""), ) assertEquals( - listOf(ChatPlanStep(step = "Replacement", status = ChatPlanStepStatus.Completed)), - controller.planSteps.value, + ChatPlanSnapshot( + steps = listOf(ChatPlanStep(step = "Replacement", status = ChatPlanStepStatus.Completed)), + explanation = "Replacement approach", + ), + controller.planSnapshot.value, ) } @@ -100,11 +110,11 @@ class ChatControllerPlanStreamTest { controller.handleGatewayEvent("agent", planPayload(runId, populated)) controller.handleGatewayEvent("agent", planPayload(runId, """{"phase":"update","steps":[]}""")) - assertTrue(controller.planSteps.value.isEmpty()) + assertEquals(ChatPlanSnapshot(steps = emptyList()), controller.planSnapshot.value) controller.handleGatewayEvent("agent", planPayload(runId, populated)) controller.handleGatewayEvent("agent", planPayload(runId, """{"phase":"update","explanation":"Revising"}""")) - assertTrue(controller.planSteps.value.isEmpty()) + assertEquals(ChatPlanSnapshot(steps = emptyList()), controller.planSnapshot.value) } @Test @@ -119,40 +129,48 @@ class ChatControllerPlanStreamTest { controller.handleGatewayEvent("chat", chatTerminalPayload("main", runId, seq = 2)) - assertTrue(controller.planSteps.value.isEmpty()) + assertEquals(ChatPlanSnapshot(steps = emptyList()), controller.planSnapshot.value) } @Test fun terminalEventForAnotherRunPreservesActivePlan() = runTest { val (controller, gateway, runId) = startRun() - val expected = listOf(ChatPlanStep(step = "Active", status = ChatPlanStepStatus.InProgress)) + val expected = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep(step = "Active", status = ChatPlanStepStatus.InProgress)), + explanation = "Stay owned", + ) controller.handleGatewayEvent( "agent", - planPayload(runId, """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}"""), + planPayload(runId, """{"phase":"update","explanation":"Stay owned","steps":[{"step":"Active","status":"in_progress"}]}"""), ) gateway.respondWith("chat.history", historyResponse(sessionId = "session-1", messages = emptyList())) controller.handleGatewayEvent("chat", chatTerminalPayload("main", "other-run", seq = 2)) - assertEquals(expected, controller.planSteps.value) + assertEquals(expected, controller.planSnapshot.value) } @Test fun wrongRunCannotReplaceCurrentPlan() = runTest { val (controller, _, runId) = startRun() - val expected = listOf(ChatPlanStep(step = "Owned", status = ChatPlanStepStatus.InProgress)) + val expected = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep(step = "Owned", status = ChatPlanStepStatus.InProgress)), + explanation = "Current run", + ) controller.handleGatewayEvent( "agent", - planPayload(runId, """{"phase":"update","steps":[{"step":"Owned","status":"in_progress"}]}"""), + planPayload(runId, """{"phase":"update","explanation":"Current run","steps":[{"step":"Owned","status":"in_progress"}]}"""), ) controller.handleGatewayEvent( "agent", - planPayload("other-run", """{"phase":"update","steps":[{"step":"Foreign","status":"completed"}]}"""), + planPayload("other-run", """{"phase":"update","explanation":"Other run","steps":[{"step":"Foreign","status":"completed"}]}"""), ) - assertEquals(expected, controller.planSteps.value) + assertEquals(expected, controller.planSnapshot.value) } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt index f9f2494b0d37..27c4b3e4831b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerReconnectRestoreTest.kt @@ -461,11 +461,15 @@ class ChatControllerReconnectRestoreTest { reconnect(controller) assertEquals( - listOf( - ChatPlanStep("Inspect", ChatPlanStepStatus.Completed), - ChatPlanStep("Reconnect", ChatPlanStepStatus.InProgress), + ChatPlanSnapshot( + steps = + listOf( + ChatPlanStep("Inspect", ChatPlanStepStatus.Completed), + ChatPlanStep("Reconnect", ChatPlanStepStatus.InProgress), + ), + explanation = "Restore checklist", ), - controller.planSteps.value, + controller.planSnapshot.value, ) } @@ -473,11 +477,12 @@ class ChatControllerReconnectRestoreTest { fun historyPlanReconciliationContract() = runTest { val retainedSteps = listOf(ChatPlanStep("Retained", ChatPlanStepStatus.InProgress)) + val retainedPlan = ChatPlanSnapshot(steps = retainedSteps, explanation = "Retained explanation") data class Case( val name: String, val history: String, - val expectedSteps: List, + val expectedPlan: ChatPlanSnapshot, val staleAfterLivePlan: Boolean = false, val snapshotForNewLiveRun: ChatPlanSnapshot? = null, val gatewayScopeChange: Boolean = false, @@ -494,9 +499,14 @@ class ChatControllerReconnectRestoreTest { inFlightPlan = ChatPlanSnapshot( steps = listOf(ChatPlanStep("Replacement", ChatPlanStepStatus.Completed)), + explanation = "Replacement explanation", ), ), - expectedSteps = listOf(ChatPlanStep("Replacement", ChatPlanStepStatus.Completed)), + expectedPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("Replacement", ChatPlanStepStatus.Completed)), + explanation = "Replacement explanation", + ), ), Case( name = "legacy-preserve", @@ -505,7 +515,7 @@ class ChatControllerReconnectRestoreTest { emptyList(), inFlightRun = "run-retained" to "working", ), - expectedSteps = retainedSteps, + expectedPlan = retainedPlan, ), Case( name = "superseded", @@ -516,9 +526,14 @@ class ChatControllerReconnectRestoreTest { inFlightPlan = ChatPlanSnapshot( steps = listOf(ChatPlanStep("Next run", ChatPlanStepStatus.InProgress)), + explanation = "Next explanation", ), ), - expectedSteps = listOf(ChatPlanStep("Next run", ChatPlanStepStatus.InProgress)), + expectedPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("Next run", ChatPlanStepStatus.InProgress)), + explanation = "Next explanation", + ), ), Case( name = "active-preserve", @@ -528,7 +543,7 @@ class ChatControllerReconnectRestoreTest { hasActiveRun = true, activeRunIds = listOf("run-retained"), ), - expectedSteps = retainedSteps, + expectedPlan = retainedPlan, ), Case( name = "terminal-clear", @@ -538,7 +553,7 @@ class ChatControllerReconnectRestoreTest { hasActiveRun = false, activeRunIds = emptyList(), ), - expectedSteps = emptyList(), + expectedPlan = ChatPlanSnapshot(steps = emptyList()), ), Case( name = "no-evidence-preserve", @@ -548,7 +563,7 @@ class ChatControllerReconnectRestoreTest { hasActiveRun = null, activeRunIds = null, ), - expectedSteps = retainedSteps, + expectedPlan = retainedPlan, ), Case( name = "stale-response-does-not-clobber-newer-live-plan", @@ -558,7 +573,11 @@ class ChatControllerReconnectRestoreTest { hasActiveRun = false, activeRunIds = emptyList(), ), - expectedSteps = listOf(ChatPlanStep("New live plan", ChatPlanStepStatus.InProgress)), + expectedPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("New live plan", ChatPlanStepStatus.InProgress)), + explanation = "New live explanation", + ), staleAfterLivePlan = true, ), Case( @@ -569,17 +588,26 @@ class ChatControllerReconnectRestoreTest { inFlightRun = "run-previous" to "stale", inFlightPlan = ChatPlanSnapshot(steps = emptyList()), ), - expectedSteps = listOf(ChatPlanStep("New live plan", ChatPlanStepStatus.InProgress)), + expectedPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("New live plan", ChatPlanStepStatus.InProgress)), + explanation = "New live explanation", + ), staleAfterLivePlan = true, ), Case( name = "snapshot-for-newer-owned-run-is-accepted", history = history(emptyList()), - expectedSteps = listOf(ChatPlanStep("Matching snapshot", ChatPlanStepStatus.Completed)), + expectedPlan = + ChatPlanSnapshot( + steps = listOf(ChatPlanStep("Matching snapshot", ChatPlanStepStatus.Completed)), + explanation = "Matching explanation", + ), staleAfterLivePlan = true, snapshotForNewLiveRun = ChatPlanSnapshot( steps = listOf(ChatPlanStep("Matching snapshot", ChatPlanStepStatus.Completed)), + explanation = "Matching explanation", ), ), Case( @@ -590,12 +618,12 @@ class ChatControllerReconnectRestoreTest { inFlightRun = "run-retained" to "working", inFlightPlan = ChatPlanSnapshot(steps = emptyList()), ), - expectedSteps = emptyList(), + expectedPlan = ChatPlanSnapshot(steps = emptyList()), ), Case( name = "gateway-scope-change-clears", history = history(emptyList()), - expectedSteps = emptyList(), + expectedPlan = ChatPlanSnapshot(steps = emptyList()), gatewayScopeChange = true, ), ) @@ -610,7 +638,7 @@ class ChatControllerReconnectRestoreTest { history( emptyList(), inFlightRun = "run-retained" to "working", - inFlightPlan = ChatPlanSnapshot(steps = retainedSteps), + inFlightPlan = retainedPlan, ), ) } @@ -633,7 +661,7 @@ class ChatControllerReconnectRestoreTest { val runId = requireNotNull(gateway.lastRunId) controller.handleGatewayEvent( "agent", - """{"sessionKey":"main","runId":"$runId","seq":1,"ts":10,"stream":"plan","data":{"phase":"update","steps":[{"step":"New live plan","status":"in_progress"}]}}""", + """{"sessionKey":"main","runId":"$runId","seq":1,"ts":10,"stream":"plan","data":{"phase":"update","explanation":"New live explanation","steps":[{"step":"New live plan","status":"in_progress"}]}}""", ) releaseHistory.complete( testCase.snapshotForNewLiveRun?.let { plan -> @@ -656,7 +684,7 @@ class ChatControllerReconnectRestoreTest { runCurrent() } - assertEquals(testCase.name, testCase.expectedSteps, controller.planSteps.value) + assertEquals(testCase.name, testCase.expectedPlan, controller.planSnapshot.value) } }