mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(android): show plan status and explanation (#124958)
This commit is contained in:
committed by
GitHub
parent
a23d19218c
commit
eeece038cd
@@ -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,
|
||||
|
||||
+42
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+47
-19
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user