mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-31 23:31:39 +00:00
fix(native): render plan proposals inline
Render ExitPlanMode and exit_plan_mode Markdown from input.plan in iOS and Android conversations. Preserve approvals, raw source and diagnostics while hiding empty output placeholders and prewarming plan documents. Add generated protocol fixtures and native regression coverage for long plans, live updates, recycling, themes and typography.
This commit is contained in:
+22
@@ -140,6 +140,28 @@ class ChatTranscriptTest {
|
||||
list.layoutInfo.visibleItemsInfo.first { it.key != "chat-history-control" }.let { it.key to it.offset }
|
||||
}
|
||||
|
||||
@Test fun planUpdatesAndRecyclingPreserveTheReadingAnchor() {
|
||||
fun plan(text: String) = previewToolCall("proposal", "ExitPlanMode", input = mapOf("plan" to text))
|
||||
val initial = "# Visible proposal\n\nRead the plan without tapping a tool."
|
||||
markdown.prepare(setOf(initial))
|
||||
mount(rows(0..19) + plan(initial) + rows(21..79))
|
||||
browse(21, 0)
|
||||
compose.onNodeWithText("Visible proposal").assertIsDisplayed()
|
||||
val before = anchor()
|
||||
val updated = "# Revised proposal\n\n" + "More plan detail. ".repeat(100)
|
||||
markdown.prepare(setOf(updated))
|
||||
compose.runOnIdle {
|
||||
state.value = state.value.copy(blocks = rows(0..19) + plan(updated) + rows(21..79), messagesVersion = 1)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("Revised proposal").assertIsDisplayed()
|
||||
assertEquals(before, anchor())
|
||||
browse(60, 0)
|
||||
browse(21, 0)
|
||||
compose.onNodeWithText("Revised proposal").assertIsDisplayed()
|
||||
compose.onNodeWithText("Visible proposal").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun tailStillFollowsTallRowGrowthAppendsAndViewportResize() {
|
||||
mount(rows(0..39))
|
||||
fun assertFollowing() {
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package app.hapi.companion.feature.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import app.hapi.companion.feature.chat.blocks.ToolCallBlockView
|
||||
import app.hapi.companion.feature.chat.blocks.previewToolCall
|
||||
import app.hapi.companion.ui.theme.HapiTheme
|
||||
import app.hapi.protocol.chat.ToolPermission
|
||||
import app.hapi.protocol.chat.ChatToolCall
|
||||
import app.hapi.protocol.chat.ToolCallBlock
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PlanProposalTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
private fun ToolCallBlock.withTool(value: ChatToolCall) = ToolCallBlock(
|
||||
id = id, localId = localId, createdAt = createdAt, invokedAt = invokedAt,
|
||||
tool = value, children = children, meta = meta,
|
||||
)
|
||||
|
||||
@Test fun plansStartExpandedUpdateInPlaceAndKeepSourceAndErrors() {
|
||||
val block = mutableStateOf(previewToolCall("proposal", "ExitPlanMode", input = mapOf("plan" to "# 实施计划\n\nRead **input.plan**.")))
|
||||
block.value = block.value.withTool(block.value.tool.copy(result = JsonNull))
|
||||
compose.setContent {
|
||||
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block.value, null) } }
|
||||
}
|
||||
compose.onNodeWithText("实施计划").assertIsDisplayed()
|
||||
compose.onNodeWithText("Read input.plan.").assertIsDisplayed()
|
||||
compose.onNodeWithText("Input").assertDoesNotExist()
|
||||
compose.onNodeWithText("Result").assertDoesNotExist()
|
||||
compose.onNodeWithText("Awaiting approval").assertDoesNotExist()
|
||||
compose.onNodeWithText("Plan proposal").performClick()
|
||||
compose.onNodeWithText("实施计划").assertDoesNotExist()
|
||||
// The same call keeps the user's explicit folding through updates.
|
||||
compose.runOnIdle {
|
||||
block.value = block.value.withTool(block.value.tool.copy(input = JsonObject(mapOf("plan" to JsonPrimitive("# Revised plan\n\nUpdated document.")))))
|
||||
}
|
||||
compose.onNodeWithText("Revised plan").assertDoesNotExist()
|
||||
compose.onNodeWithText("Plan proposal").performClick()
|
||||
compose.onNodeWithText("Revised plan").assertIsDisplayed()
|
||||
compose.onNodeWithText("实施计划").assertDoesNotExist()
|
||||
compose.onNodeWithText("Source").performClick()
|
||||
compose.waitUntil(10_000) { compose.onAllNodesWithText("\"plan\"", substring = true).fetchSemanticsNodes().isNotEmpty() }
|
||||
compose.onNodeWithText("Input").assertIsDisplayed()
|
||||
compose.onNodeWithText("Source").performClick()
|
||||
compose.runOnIdle {
|
||||
block.value = block.value.withTool(block.value.tool.copy(state = "error", result = JsonPrimitive("Plan failed to apply")))
|
||||
}
|
||||
compose.waitUntil(10_000) { compose.onAllNodesWithText("Plan failed to apply", substring = true).fetchSemanticsNodes().isNotEmpty() }
|
||||
compose.onNodeWithText("Plan failed to apply", substring = true).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun lowercaseProposalOpensWhenInputArrivesAndPrecedesApproval() {
|
||||
val block = mutableStateOf(previewToolCall("proposal", "unknown"))
|
||||
compose.setContent {
|
||||
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block.value, null) } }
|
||||
}
|
||||
compose.runOnIdle {
|
||||
block.value = previewToolCall("proposal", "exit_plan_mode", input = mapOf("plan" to "## Review first\n\nThen approve."),
|
||||
permission = ToolPermission(id = "approval", status = "pending", presence = emptySet()))
|
||||
}
|
||||
compose.onNodeWithText("Review first").assertIsDisplayed()
|
||||
val document = compose.onNodeWithText("Then approve.").fetchSemanticsNode().boundsInRoot
|
||||
val approval = compose.onNodeWithText("⏳ Awaiting approval").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("Read the plan before approval", document.bottom <= approval.top)
|
||||
}
|
||||
|
||||
@Test fun longPlansRemainFullMarkdownInsteadOfPagedToolSource() {
|
||||
val source = "# Long plan\n\n" + "A complete paragraph 中文. ".repeat(1_000) + "\n\n## Last heading"
|
||||
assertTrue(source.length > 20_000)
|
||||
val block = previewToolCall("long-plan", "exit_plan_mode", input = mapOf("plan" to source))
|
||||
compose.setContent {
|
||||
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block, null) } }
|
||||
}
|
||||
compose.onNodeWithText("Long plan").assertIsDisplayed()
|
||||
compose.onNodeWithText("Last heading").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithText("Load more content").assertDoesNotExist()
|
||||
compose.onNodeWithText("Source").assertExists()
|
||||
}
|
||||
|
||||
@Test fun ordinaryToolsRemainCollapsedAndMalformedPlanInputStaysAccessible() {
|
||||
val block = mutableStateOf(previewToolCall("ordinary", "Bash", input = mapOf("command" to "echo hidden")))
|
||||
compose.setContent {
|
||||
HapiTheme { Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block.value, null) } }
|
||||
}
|
||||
compose.onNodeWithText("Source").assertDoesNotExist()
|
||||
compose.runOnIdle { block.value = previewToolCall("malformed", "ExitPlanMode", input = mapOf("unexpected" to "retained")) }
|
||||
compose.waitUntil(10_000) { compose.onAllNodesWithText("retained", substring = true).fetchSemanticsNodes().isNotEmpty() }
|
||||
compose.onNodeWithText("retained", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun plansReflowAcrossThemeAndFontScaleChanges() {
|
||||
val dark = mutableStateOf(false)
|
||||
val scale = mutableStateOf(1f)
|
||||
val plan = "# 实施计划\n\n- Inspect **input.plan**\n\n| Client | Ready |\n| --- | --- |\n| Android | Yes |\n\n```kotlin\nval ready = true\n```\n\n[Documentation](https://hapi.run)\n\nEnd of plan"
|
||||
val block = previewToolCall("theme-plan", "ExitPlanMode", input = mapOf("plan" to plan))
|
||||
compose.setContent {
|
||||
HapiTheme(darkTheme = dark.value, dynamicColor = false) {
|
||||
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale = scale.value)) {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) { ToolCallBlockView(block, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("实施计划").assertIsDisplayed()
|
||||
val normalHeight = compose.onNodeWithText("End of plan").fetchSemanticsNode().boundsInRoot.height
|
||||
compose.runOnIdle { dark.value = true }
|
||||
compose.onNodeWithText("End of plan").performScrollTo().assertIsDisplayed()
|
||||
compose.runOnIdle { scale.value = 2f }
|
||||
compose.onNodeWithText("实施计划").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithText("End of plan").performScrollTo().assertIsDisplayed()
|
||||
assertTrue(compose.onNodeWithText("End of plan").fetchSemanticsNode().boundsInRoot.height > normalHeight)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.hapi.companion.feature.chat
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import app.hapi.companion.feature.chat.attachments.ComposerAttachments
|
||||
import app.hapi.companion.feature.chat.blocks.planProposalMarkdown
|
||||
import app.hapi.companion.feature.chat.composer.ChatDrafts
|
||||
import app.hapi.companion.feature.chat.composer.SlashCommands
|
||||
import app.hapi.companion.feature.chat.composer.appendTranscript
|
||||
@@ -31,6 +32,7 @@ import app.hapi.protocol.catalog.PermissionMode
|
||||
import app.hapi.protocol.catalog.PermissionModes
|
||||
import app.hapi.protocol.chat.NormalizedMessage
|
||||
import app.hapi.protocol.chat.ToolGroupBlock
|
||||
import app.hapi.protocol.chat.ToolCallBlock
|
||||
import app.hapi.protocol.chat.ToolGroupingOptions
|
||||
import app.hapi.protocol.chat.VisibleChatBlock
|
||||
import app.hapi.protocol.chat.buildVisibleChatBlocks
|
||||
@@ -1671,6 +1673,7 @@ class ChatViewModel(
|
||||
when (block) {
|
||||
is AgentTextBlock -> block.text
|
||||
is AgentReasoningBlock -> block.text
|
||||
is ToolCallBlock -> planProposalMarkdown(block.tool)
|
||||
else -> null
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
@@ -46,16 +46,22 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
* - `Write` → the written content as a code block;
|
||||
* - `CodexDiff` (and any input/result that parses as a unified diff) → [DiffView];
|
||||
* - `TodoWrite`/`update_plan` → checklist rows;
|
||||
* - `ExitPlanMode`/`exit_plan_mode` → complete Markdown proposal from input;
|
||||
* - Ask/RequestUserInput → questions + selected answers, read-only;
|
||||
* - anything else → pretty-printed JSON input, then the generic result.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ToolCallBody(tool: ChatToolCall, basePath: String?, modifier: Modifier = Modifier) {
|
||||
val questionTool = isQuestionDetailsTool(tool.name)
|
||||
val plan = planProposalMarkdown(tool)
|
||||
val answers = if (questionTool) tool.permission?.answers else null
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (questionTool) {
|
||||
QuestionToolBody(tool)
|
||||
} else if (plan != null) {
|
||||
// Plans are reading documents, never paged/truncated tool source.
|
||||
Markdown(text = plan)
|
||||
if (planProposalShowsResult(tool)) ToolResultSection(tool)
|
||||
} else {
|
||||
SectionLabel(stringResource(R.string.settings_usage_input))
|
||||
ToolInputSection(tool)
|
||||
|
||||
+15
-4
@@ -51,8 +51,8 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
* One tool invocation (web `ToolCard`): collapsed header row — icon glyph,
|
||||
* title, subtitle, status — expanding to the per-tool body ([ToolCallBody]),
|
||||
* the read-only permission state, and nested children (sidechain transcript).
|
||||
* Cards with a pending permission start expanded and carry the
|
||||
* "awaiting approval" banner (actions land in M3b).
|
||||
* Plan proposals and pending permissions start expanded. Only actual pending
|
||||
* permissions carry the "awaiting approval" banner.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifier = Modifier) {
|
||||
@@ -60,7 +60,10 @@ fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifie
|
||||
val resources = LocalContext.current.resources
|
||||
val presentation = remember(tool, basePath, resources) { toolCardPresentation(tool, basePath, resources) }
|
||||
val pendingPermission = tool.permission?.status == "pending"
|
||||
var expanded by rememberSaveable(block.id) { mutableStateOf(pendingPermission) }
|
||||
val planProposal = isPlanProposalTool(tool.name)
|
||||
// An output-first placeholder can acquire its real tool name later. Open a
|
||||
// newly recognized plan, but retain explicit folding across input updates.
|
||||
var expanded by rememberSaveable(block.id, planProposal) { mutableStateOf(pendingPermission || planProposal) }
|
||||
val colors = MaterialTheme.hapi
|
||||
|
||||
Surface(
|
||||
@@ -99,6 +102,14 @@ fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifie
|
||||
ToolStatusIndicator(tool.state)
|
||||
}
|
||||
|
||||
if (planProposal && expanded) {
|
||||
ToolCallBody(
|
||||
tool = tool,
|
||||
basePath = basePath,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
tool.permission?.let { permission ->
|
||||
val interactions = LocalChatInteractions.current
|
||||
if (permission.status == "pending" && interactions != null) {
|
||||
@@ -126,7 +137,7 @@ fun ToolCallBlockView(block: ToolCallBlock, basePath: String?, modifier: Modifie
|
||||
}
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
if (!planProposal && expanded) {
|
||||
ToolCallBody(
|
||||
tool = tool,
|
||||
basePath = basePath,
|
||||
|
||||
+14
@@ -22,6 +22,20 @@ internal fun toolSourceInput(input: JsonElement?, keys: List<String>): String? =
|
||||
|
||||
private fun JsonElement?.textValue(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
internal fun isPlanProposalTool(name: String): Boolean = name == "ExitPlanMode" || name == "exit_plan_mode"
|
||||
|
||||
/** A proposal is input.plan Markdown, not update_plan's checklist or a result. */
|
||||
internal fun planProposalMarkdown(tool: ChatToolCall): String? =
|
||||
if (isPlanProposalTool(tool.name)) (tool.input as? JsonObject)?.get("plan").textValue()?.takeIf { it.isNotBlank() }
|
||||
else null
|
||||
|
||||
internal fun planProposalShowsResult(tool: ChatToolCall): Boolean {
|
||||
if (tool.state == "error") return true
|
||||
val result = tool.result ?: return false
|
||||
if (result is JsonNull) return false
|
||||
return result.textValue()?.isNotBlank() ?: true
|
||||
}
|
||||
|
||||
/** Mixed content stays JSON, rather than silently discarding images/resources. */
|
||||
internal fun extractResultText(result: JsonElement, depth: Int = 0): String? {
|
||||
if (depth > 4) return null
|
||||
|
||||
@@ -649,6 +649,11 @@ class ChatViewModelTest {
|
||||
blocks.filterIsInstance<ToolCallBlock>().any { it.tool.name == "Bash" }
|
||||
},
|
||||
"claude-assistant-text.json" to { blocks -> blocks.any { it is AgentTextBlock } },
|
||||
"codex-plan-proposal-completed.json" to { blocks ->
|
||||
blocks.filterIsInstance<ToolCallBlock>().any {
|
||||
app.hapi.companion.feature.chat.blocks.planProposalMarkdown(it.tool) != null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
for ((name, expectation) in fixtures) {
|
||||
@@ -673,6 +678,11 @@ class ChatViewModelTest {
|
||||
harness.viewModel.start()
|
||||
val state = harness.viewModel.uiState.first { it.blocks.isNotEmpty() }
|
||||
assertTrue(expectation(state.blocks), "fixture $name should satisfy its block expectation")
|
||||
for (block in state.blocks.filterIsInstance<ToolCallBlock>()) {
|
||||
app.hapi.companion.feature.chat.blocks.planProposalMarkdown(block.tool)?.let { plan ->
|
||||
assertTrue(harness.viewModel.markdownCache.cached(plan) != null, "Plans must be prepared before publication")
|
||||
}
|
||||
}
|
||||
harness.viewModel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -6,8 +6,10 @@ import app.hapi.protocol.wire.HapiJson
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
@@ -30,6 +32,33 @@ class ToolContentPresentationTest {
|
||||
assertNull(toolSourceInput(json("""{"unknown":true}"""), listOf("patch")))
|
||||
}
|
||||
|
||||
@Test fun planProposalsReadInputAndPreserveTheCompleteDocument() {
|
||||
val plan = "\n# 计划 👩🏽💻\n\n" + "- Inspect **input.plan** \n".repeat(1_000) + "last \n"
|
||||
for (name in listOf("ExitPlanMode", "exit_plan_mode")) {
|
||||
val input = JsonObject(mapOf("plan" to JsonPrimitive(plan), "extra" to JsonPrimitive(true)))
|
||||
val call = tool(name, input, JsonNull)
|
||||
assertTrue(isPlanProposalTool(name))
|
||||
assertEquals(plan, planProposalMarkdown(call))
|
||||
assertEquals(input, call.input)
|
||||
assertFalse(planProposalShowsResult(call))
|
||||
for (result in listOf(JsonPrimitive("Approved"), json("""{"error":"Failed"}"""))) {
|
||||
assertTrue(planProposalShowsResult(tool(name, input, result)))
|
||||
}
|
||||
for (result in listOf(null, JsonNull, JsonPrimitive(" \n"))) {
|
||||
assertFalse(planProposalShowsResult(tool(name, input, result)))
|
||||
}
|
||||
assertTrue(planProposalShowsResult(tool(name, input, state = "error")))
|
||||
for (value in listOf("null", "[]", "{}", """{"plan":null}""", """{"plan":42}""",
|
||||
"""{"plan":[]}""", """{"plan":" \n"}""", """"a raw string"""")) {
|
||||
assertNull(planProposalMarkdown(tool(name, json(value))), value)
|
||||
}
|
||||
}
|
||||
val input = JsonObject(mapOf("plan" to JsonPrimitive(plan)))
|
||||
assertNull(planProposalMarkdown(tool("update_plan", input)))
|
||||
assertNull(planProposalMarkdown(tool("mcp__server__ExitPlanMode", input)))
|
||||
assertNull(planProposalMarkdown(tool("update_plan", json("""{"plan":[{"step":"Inspect","status":"completed"}]}"""))))
|
||||
}
|
||||
|
||||
@Test fun resultEnvelopesAndWhitespace() {
|
||||
for (payload in listOf("""{"output":"hello\n"}""", """{"result":{"data":{"text":"hello\n"}}}""",
|
||||
"""{"content":["hello\n"]}""", """{"file":{"content":"hello\n"}}""",
|
||||
|
||||
Reference in New Issue
Block a user