fix(android): show plan status and explanation (#124958)

This commit is contained in:
Peter Steinberger
2026-08-16 19:11:10 -07:00
committed by GitHub
parent a23d19218c
commit eeece038cd
6 changed files with 161 additions and 90 deletions
@@ -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<Map<String, ai.openclaw.app.chat.ChatSubagentActivity>> =
runtimeState(initial = emptyMap()) { it.chatSubagentActivities }
val chatQuestions: StateFlow<List<ChatQuestionPrompt>> = runtimeState(initial = emptyList()) { it.chatQuestions }
val chatPlanSteps: StateFlow<List<ChatPlanStep>> = runtimeState(initial = emptyList()) { it.chatPlanSteps }
val chatPlanSnapshot: StateFlow<ChatPlanSnapshot> =
runtimeState(initial = ChatPlanSnapshot(steps = emptyList())) { it.chatPlanSnapshot }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val chatSwarmGroups: StateFlow<List<ChatSwarmGroup>> = runtimeState(initial = emptyList()) { it.chatSwarmGroups }
val chatSessionBranches: StateFlow<List<SessionBranch>> = runtimeState(initial = emptyList()) { it.chatSessionBranches }
@@ -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<List<ChatPendingToolCall>> = chat.pendingToolCalls
val chatSubagentActivities: StateFlow<Map<String, ai.openclaw.app.chat.ChatSubagentActivity>> = chat.subagentActivities
val chatQuestions: StateFlow<List<ChatQuestionPrompt>> = chat.questions
val chatPlanSteps: StateFlow<List<ChatPlanStep>> = chat.planSteps
val chatPlanSnapshot: StateFlow<ChatPlanSnapshot> = chat.planSnapshot
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
val chatSwarmGroups: StateFlow<List<ChatSwarmGroup>> = chat.swarmGroups
val chatSessionBranches: StateFlow<List<SessionBranch>> = chat.sessionBranches
@@ -354,8 +354,8 @@ class ChatController internal constructor(
private val questionEvictionJobs = mutableMapOf<String, QuestionEvictionJob>()
private val _planSteps = MutableStateFlow<List<ChatPlanStep>>(emptyList())
val planSteps: StateFlow<List<ChatPlanStep>> = _planSteps.asStateFlow()
private val _planSnapshot = MutableStateFlow(ChatPlanSnapshot(steps = emptyList()))
val planSnapshot: StateFlow<ChatPlanSnapshot> = _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(
@@ -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<ChatPlanStep>) {
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<ChatPlanStep>) {
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<ChatPlanStep>) {
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<ChatPlanStep>) {
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<ChatPlanStep>) {
}
}
@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,
@@ -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)
}
}
@@ -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<ChatPlanStep>,
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)
}
}