From d29713aedadd7f5d7bd6111f4ad62dfc3f8f5cce Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 12 Sep 2026 16:06:16 +0800 Subject: [PATCH] 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. --- android/README.md | 5 + .../feature/chat/ChatTranscriptTest.kt | 22 ++ .../feature/chat/PlanProposalTest.kt | 130 ++++++++++ .../companion/feature/chat/ChatViewModel.kt | 3 + .../feature/chat/blocks/ToolBodies.kt | 6 + .../feature/chat/blocks/ToolCallBlockView.kt | 19 +- .../chat/blocks/ToolContentPresentation.kt | 14 ++ .../feature/chat/ChatViewModelTest.kt | 10 + .../blocks/ToolContentPresentationTest.kt | 29 +++ docs/native-chat-scrolling.md | 11 +- .../Features/Chat/Blocks/ToolBodies.swift | 15 ++ .../Chat/Blocks/ToolCallBlockView.swift | 7 +- .../Chat/Blocks/ToolContentPresentation.swift | 23 ++ ios/Hapi/Features/Chat/ChatModel.swift | 1 + .../PlanProposalPresentationTests.swift | 106 +++++++++ .../ToolContentPresentationTests.swift | 26 ++ .../ToolTranscriptPresentationTests.swift | 77 ++++++ ios/README.md | 7 + .../chat/codex-plan-proposal-completed.json | 224 ++++++++++++++++++ .../chat/codex-plan-proposal-pending.json | 221 +++++++++++++++++ web/scripts/fixtures/cases/codex.ts | 24 ++ 21 files changed, 973 insertions(+), 7 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/PlanProposalTest.kt create mode 100644 ios/HapiTests/PlanProposalPresentationTests.swift create mode 100644 shared/fixtures/chat/codex-plan-proposal-completed.json create mode 100644 shared/fixtures/chat/codex-plan-proposal-pending.json diff --git a/android/README.md b/android/README.md index 1921b562..a1250d0f 100644 --- a/android/README.md +++ b/android/README.md @@ -62,6 +62,11 @@ CI (`.github/workflows/android.yml`) runs the protocol tests and ## Tool previews +Plan proposals (`ExitPlanMode` / `exit_plan_mode`) start fully expanded, rendering +the complete `input.plan` Markdown before approval controls. Users can still fold +the card. Plan documents are prewarmed in the chat Markdown cache and do not use +the ordinary tool-output paging budget; raw input/result remains under Source. + Expanded cards recognize namespaced command/script/patch calls, unwrap common nested result envelopes, and keep command exit/status metadata visible. File reads use source-language highlighting; web/agent prose uses Markdown. **Source** diff --git a/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatTranscriptTest.kt b/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatTranscriptTest.kt index e314f71f..535fc810 100644 --- a/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatTranscriptTest.kt +++ b/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/ChatTranscriptTest.kt @@ -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() { diff --git a/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/PlanProposalTest.kt b/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/PlanProposalTest.kt new file mode 100644 index 00000000..afa41680 --- /dev/null +++ b/android/app/src/androidTest/kotlin/app/hapi/companion/feature/chat/PlanProposalTest.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt index e3a908c8..b340b848 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -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() diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt index 9d306df1..a44b6415 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolBodies.kt @@ -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) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt index bf88b293..21881e6e 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolCallBlockView.kt @@ -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, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentation.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentation.kt index f63e47d7..3acac4e7 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentation.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentation.kt @@ -22,6 +22,20 @@ internal fun toolSourceInput(input: JsonElement?, keys: List): 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 diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt index 00271dd3..414f26f1 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/ChatViewModelTest.kt @@ -649,6 +649,11 @@ class ChatViewModelTest { blocks.filterIsInstance().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().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()) { + 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() } } diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentationTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentationTest.kt index 5df950fa..d9094b85 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentationTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/blocks/ToolContentPresentationTest.kt @@ -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"}}""", diff --git a/docs/native-chat-scrolling.md b/docs/native-chat-scrolling.md index 2f87cdaf..662bd7aa 100644 --- a/docs/native-chat-scrolling.md +++ b/docs/native-chat-scrolling.md @@ -1,7 +1,14 @@ # Native transcript scrolling -Native-only presentation policy. No Web, Hub, REST/SSE format, or generated -fixture changes. iOS and Android still reduce the same protocol messages. +Native-only presentation policy. No Web, Hub or REST/SSE format changes. +iOS and Android still reduce the same protocol messages; golden fixtures pin +that contract, not the native view appearance. + +Plan proposals are an exception to compact tool summaries: both native clients +show the complete input document as Markdown by default, before approvals. +Android retains explicit manual folding; iOS retains its separate inspector. +Plan text joins off-main Markdown preparation before transcript publication; +stable tool IDs and normal self-sizing/anchor compensation remain unchanged. ## Behavior diff --git a/ios/Hapi/Features/Chat/Blocks/ToolBodies.swift b/ios/Hapi/Features/Chat/Blocks/ToolBodies.swift index 964590ec..d39e6c32 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolBodies.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolBodies.swift @@ -13,6 +13,7 @@ import SwiftUI /// - `CodexDiff` (and any input/result that parses as a unified diff) → /// `DiffTextView`; /// - `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. struct ToolCallBody: View { @@ -26,6 +27,9 @@ struct ToolCallBody: View { VStack(alignment: .leading, spacing: 12) { if questionTool { QuestionToolBody(tool: tool) + } else if let plan = planProposalMarkdown(tool) { + PlanProposalContent(markdown: plan) + if planProposalShowsResult(tool) { ToolResultSection(tool: tool) } } else { SectionLabel(text: String(localized: "Input")) ToolInputSection(tool: tool, basePath: basePath) @@ -53,6 +57,17 @@ struct ToolCallBody: View { } } +/// Shared by the transcript and inspector. Unlike ordinary tool output, the +/// proposal is a reading document: no preview budget or paged-source fallback. +struct PlanProposalContent: View { + let markdown: String + + var body: some View { + CachedMarkdownView(markdown: markdown) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private struct QuestionToolBody: View { let tool: ChatToolCall @State private var details: QuestionToolDetails? diff --git a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift index 58264cb8..0ad8d401 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift @@ -4,7 +4,7 @@ import HapiUI import SwiftUI /// A bounded activity summary. Ordinary tools open the shared inspector; -/// sidechains open their own transcript. Questions and approvals stay inline. +/// sidechains open their own transcript. Questions, plans and approvals stay inline. struct ToolCallBlockView: View { let block: ToolCallBlock let basePath: String? @@ -27,6 +27,11 @@ struct ToolCallBlockView: View { let presentation = toolSummaryPresentation(block.tool, basePath: basePath) VStack(alignment: .leading, spacing: 0) { headerRow(presentation) + if let plan = planProposalMarkdown(block.tool) { + PlanProposalContent(markdown: plan) + .padding(12) + .accessibilityIdentifier("plan-proposal-\(block.id)") + } if let permission = block.tool.permission { if permission.status == .pending, let interactions { pendingApprovalSection(permission: permission, interactions: interactions) diff --git a/ios/Hapi/Features/Chat/Blocks/ToolContentPresentation.swift b/ios/Hapi/Features/Chat/Blocks/ToolContentPresentation.swift index e31dee8b..6fb6ffa6 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolContentPresentation.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolContentPresentation.swift @@ -19,6 +19,29 @@ func toolSourceInput(_ input: JSONValue?, keys: [String]) -> String? { input?.chatString ?? chatInputString(input, keys) } +func isPlanProposalTool(_ name: String) -> Bool { + name == "ExitPlanMode" || name == "exit_plan_mode" +} + +/// Plans are documents in input.plan, not update_plan's checklist or a tool result. +func planProposalMarkdown(_ tool: ChatToolCall) -> String? { + guard isPlanProposalTool(tool.name), + let plan = tool.input?[chatKey: "plan"]?.chatString, + !plan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return plan +} + +/// A synthesized Codex proposal completes with null output. Keep diagnostics, +/// but don't suggest that its visible document is still waiting for output. +func planProposalShowsResult(_ tool: ChatToolCall) -> Bool { + if tool.state == .error { return true } + guard let result = tool.result, result != .null else { return false } + if let text = result.chatString { + return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return true +} + /// Bounded wrapper traversal; mixed/non-text blocks deliberately retain their /// JSON representation rather than silently dropping images or resources. func extractResultText(_ result: JSONValue, depth: Int = 0) -> String? { diff --git a/ios/Hapi/Features/Chat/ChatModel.swift b/ios/Hapi/Features/Chat/ChatModel.swift index 04bf9a33..f7cffb01 100644 --- a/ios/Hapi/Features/Chat/ChatModel.swift +++ b/ios/Hapi/Features/Chat/ChatModel.swift @@ -464,6 +464,7 @@ final class ChatModel { switch value { case .agentText(let text): return [text.text] case .agentReasoning(let text): return [text.text] + case .toolCall(let block): return planProposalMarkdown(block.tool).map { [$0] } ?? [] default: return [] } }) diff --git a/ios/HapiTests/PlanProposalPresentationTests.swift b/ios/HapiTests/PlanProposalPresentationTests.swift new file mode 100644 index 00000000..a33040e2 --- /dev/null +++ b/ios/HapiTests/PlanProposalPresentationTests.swift @@ -0,0 +1,106 @@ +import HapiUI +import SwiftUI +import UIKit +import XCTest +@testable import Hapi +@testable import HapiProtocol + +@MainActor +final class PlanProposalPresentationTests: XCTestCase { + private let plan = """ + # 实施计划 + + 1. Read **input.plan**, not the result. + 2. Render the complete document on both native clients. + + | Client | Renderer | + | --- | --- | + | iOS | SwiftUI Markdown | + | Android | Compose Markdown | + + ```swift + let ready = true + ``` + + [HAPI documentation](https://hapi.run) + """ + + private func block(_ name: String, plan: String?, result: JSONValue? = .null) -> ToolCallBlock { + ToolCallBlock(id: "proposal", localId: nil, createdAt: 0, invokedAt: nil, + durationMs: nil, usage: nil, model: nil, + tool: ChatToolCall(id: "proposal", name: name, state: .completed, + input: plan.map { .object(["plan": .string($0)]) }, + createdAt: 0, result: result), children: [], meta: nil) + } + + private func height(_ view: V, width: CGFloat = 320, size: DynamicTypeSize = .large) -> CGFloat { + UIHostingController(rootView: view.hapiTypography().environment(\.dynamicTypeSize, size)) + .sizeThatFits(in: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)).height + } + + func testBothAliasesShowTheEntireMarkdownLayoutWithoutOpeningDetails() { + let longPlan = plan + "\n\n" + String(repeating: "Complete paragraph 中文 👩🏽‍💻. ", count: 900) + "\n\nLast paragraph." + XCTAssertGreaterThan(longPlan.count, toolTextPageSize) + for name in ["ExitPlanMode", "exit_plan_mode"] { + for source in [plan, longPlan] { + // Compare with the full production Markdown renderer, not the + // plan component itself: truncation/paged source must fail. + let markdownHeight = height(CachedMarkdownView(markdown: source), width: 296) + let headerHeight = height(ToolCallBlockView(block: block(name, plan: nil), basePath: nil)) + let inline = height(ToolCallBlockView(block: block(name, plan: source), basePath: nil)) + XCTAssertEqual(inline, markdownHeight + headerHeight + 24, accuracy: 2, name) + let details = height(ToolCallBody(tool: block(name, plan: source).tool, basePath: nil)) + XCTAssertGreaterThan(details, height(CachedMarkdownView(markdown: source))) + } + } + } + + func testNullResultDoesNotAddAnOutputPlaceholderAndDynamicTypeStillReflows() { + let withoutResult = block("ExitPlanMode", plan: plan, result: nil) + let nullResult = block("ExitPlanMode", plan: plan) + XCTAssertEqual(height(ToolCallBody(tool: withoutResult.tool, basePath: nil)), + height(ToolCallBody(tool: nullResult.tool, basePath: nil)), accuracy: 1) + let normal = height(ToolCallBlockView(block: nullResult, basePath: nil)) + let accessible = height(ToolCallBlockView(block: nullResult, basePath: nil), size: .accessibility3) + XCTAssertGreaterThan(accessible, normal) + var pending = nullResult + pending.tool.permission = ToolPermission(id: "approval", status: .pending) + XCTAssertGreaterThan(height(ToolCallBlockView(block: pending, basePath: nil)), normal) + XCTAssertNil(nullResult.tool.permission, "A completed proposal must not invent approval") + } + + func testPlanSpecimensInLightDarkAndLargeType() async throws { + guard let directory = ProcessInfo.processInfo.environment["HAPI_TOOL_CAPTURE"] else { return } + for (name, theme, size) in [("light", HapiTheme.light, DynamicTypeSize.large), + ("dark", HapiTheme.dark, .large), ("large-type", HapiTheme.light, .accessibility2)] { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + let cache = MarkdownRenderCache() + await cache.prepare([plan]) + let view = NavigationStack { + ScrollView { + ToolCallBlockView(block: block("ExitPlanMode", plan: plan), basePath: nil) + .hapiReadingColumn().padding(.vertical, 16) + } + .background(theme.background) + .navigationTitle("Plan proposal") + .navigationBarTitleDisplayMode(.inline) + } + .hapiTypography().hapiTheme(theme) + .environment(\.hapiMarkdownCache, cache) + .environment(\.dynamicTypeSize, size) + .preferredColorScheme(theme.isDark ? .dark : .light) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + defer { window.isHidden = true } + try await Task.sleep(for: .milliseconds(350)) + let url = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try XCTUnwrap(image.pngData()).write(to: url.appendingPathComponent("plan-\(name).png")) + } + } +} diff --git a/ios/HapiTests/ToolContentPresentationTests.swift b/ios/HapiTests/ToolContentPresentationTests.swift index b86e6a18..1afa08d7 100644 --- a/ios/HapiTests/ToolContentPresentationTests.swift +++ b/ios/HapiTests/ToolContentPresentationTests.swift @@ -25,6 +25,32 @@ final class ToolContentPresentationTests: XCTestCase { XCTAssertNil(toolSourceInput(.object(["unknown": .bool(true)]), keys: ["patch"])) } + func testPlanProposalsReadInputAndPreserveTheCompleteDocument() throws { + let plan = "\n# 计划 👩🏽‍💻\n\n" + String(repeating: "- Inspect **input.plan** \n", count: 1_000) + "last \n" + for name in ["ExitPlanMode", "exit_plan_mode"] { + let input: JSONValue = .object(["plan": .string(plan), "extra": .bool(true)]) + let call = tool(name, input: input, result: .null) + XCTAssertTrue(isPlanProposalTool(name)) + XCTAssertEqual(planProposalMarkdown(call), plan) + XCTAssertEqual(call.input, input) + XCTAssertFalse(planProposalShowsResult(call)) + for result in [JSONValue.string("Approved"), .object(["error": .string("Failed")])] { + XCTAssertTrue(planProposalShowsResult(tool(name, input: input, result: result))) + } + for result in [nil, JSONValue.null, .string(" \n")] { + XCTAssertFalse(planProposalShowsResult(tool(name, input: input, result: result))) + } + XCTAssertTrue(planProposalShowsResult(tool(name, input: input, state: .error))) + for input in ["null", "[]", "{}", #"{"plan":null}"#, #"{"plan":42}"#, + #"{"plan":[]}"#, #"{"plan":" \n"}"#, #""a raw string""#] { + XCTAssertNil(planProposalMarkdown(tool(name, input: try json(input))), input) + } + } + XCTAssertNil(planProposalMarkdown(tool("update_plan", input: .object(["plan": .string(plan)])))) + XCTAssertNil(planProposalMarkdown(tool("mcp__server__ExitPlanMode", input: .object(["plan": .string(plan)])))) + XCTAssertEqual(checklistItems(try json(#"{"plan":[{"step":"Inspect","status":"completed"}]}"#)).first?.glyph, "☑") + } + func testResultEnvelopesAndWhitespace() throws { for payload in [#"{"output":"hello\n"}"#, #"{"result":{"data":{"text":"hello\n"}}}"#, #"{"content":["hello\n"]}"#, #"{"file":{"content":"hello\n"}}"#, diff --git a/ios/HapiTests/ToolTranscriptPresentationTests.swift b/ios/HapiTests/ToolTranscriptPresentationTests.swift index 7ca16821..1ca81bb3 100644 --- a/ios/HapiTests/ToolTranscriptPresentationTests.swift +++ b/ios/HapiTests/ToolTranscriptPresentationTests.swift @@ -30,6 +30,7 @@ final class ToolTranscriptPresentationTests: XCTestCase { .hapiTheme(theme) .environment(\.dynamicTypeSize, size) .environment(\.colorScheme, theme.isDark ? .dark : .light) + .environment(\.hapiMarkdownCache, model.markdownCache) .preferredColorScheme(theme.isDark ? .dark : .light) } } @@ -128,6 +129,73 @@ final class ToolTranscriptPresentationTests: XCTestCase { } } + func testPlanPublicationUpdatesRecyclingAndInspectorPreserveTheDocumentAndAnchor() async throws { + let plan = "# Initial proposal\n\nRead the document without opening the inspector." + let credentials = InMemoryCredentialStore() + let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString() + try credentials.store(HubCredentials(hubUrl: "http://127.0.0.1:1", accessToken: "test", jwt: "e30.\(payload).test")) + let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/plan-transcript-\(UUID().uuidString)", + credentialStore: credentials, performer: ToolTranscriptHTTP(proposedPlan: plan))) + let model = ChatModel(session: hub, sessionId: "tool-transcript") + defer { model.stop(); hub.shutdown() } + model.start() + try await eventually { !model.blocks.isEmpty && !model.isSyncingTail } + XCTAssertNotNil(model.markdownCache.cached(plan), "Prewarm plan Markdown before publishing rows") + let index = try XCTUnwrap(model.blocks.firstIndex { $0.stableId == "proposal" }) + 1 + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + let host = UIHostingController(rootView: Harness(model: model, session: hub, theme: .light, size: .large)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { window.isHidden = true } + host.view.layoutIfNeeded() + try await eventually { findCollection(host.view) != nil } + let collection = try XCTUnwrap(findCollection(host.view)) + try await eventually { collection.numberOfItems(inSection: 0) == model.blocks.count + 1 } + collection.delegate?.scrollViewWillBeginDragging?(collection) + collection.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false) + try await eventually { collection.cellForItem(at: IndexPath(item: index, section: 0)) != nil } + try await Task.sleep(for: .milliseconds(200)) + let cell = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: index, section: 0))) + let initialHeight = cell.frame.height + XCTAssertGreaterThan(initialHeight, 100, "A plan must not remain a summary row") + let anchorY = cell.frame.minY - collection.contentOffset.y + let updated = "# Revised proposal\n\n" + String(repeating: "More plan detail. ", count: 150) + let controller = await hub.windows.open(sessionId: "tool-transcript") + let seq = (await controller.state.newestSeq ?? 0) + 1 + let message = DecryptedMessage(id: "plan-update", seq: seq, + content: ["role": "agent", "content": ["type": "codex", "data": [ + "type": "tool-call", "callId": "proposal", "name": "ExitPlanMode", + "input": ["plan": .string(updated)], + ]]], createdAt: seq * 1000, invokedAt: seq * 1000) + await controller.onMessageEvent(.messageReceived(namespace: nil, sessionId: "tool-transcript", message: message)) + try await eventually { model.toolInspection.tools["proposal"]?.tool.input?[chatKey: "plan"]?.chatString == updated } + XCTAssertNotNil(model.markdownCache.cached(updated)) + try await eventually { (collection.cellForItem(at: IndexPath(item: index, section: 0))?.frame.height ?? 0) > initialHeight + 100 } + XCTAssertEqual(cell.frame.minY - collection.contentOffset.y, anchorY, accuracy: 1) + let count = collection.numberOfItems(inSection: 0) + collection.scrollToItem(at: IndexPath(item: count - 1, section: 0), at: .bottom, animated: false) + try await Task.sleep(for: .milliseconds(150)) + XCTAssertNil(collection.cellForItem(at: IndexPath(item: index, section: 0))) + collection.delegate?.scrollViewWillBeginDragging?(collection) + collection.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false) + try await Task.sleep(for: .milliseconds(200)) + let returned = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: index, section: 0))) + XCTAssertGreaterThan(returned.frame.height, initialHeight + 100) + let restoredY = returned.frame.minY - collection.contentOffset.y + model.beginContentInspection() + model.retainSurface("inspector:chat") + model.toolInspection.open(try XCTUnwrap(model.toolInspection.tools["proposal"]), owner: "chat") + try await eventually { host.presentedViewController != nil } + XCTAssertEqual(planProposalMarkdown(try XCTUnwrap(model.toolInspection.selection).block.tool), updated) + model.toolInspection.dismiss(owner: "chat") + try await eventually { host.presentedViewController == nil && !model.isInspectingContent } + XCTAssertEqual(returned.frame.minY - collection.contentOffset.y, restoredY, accuracy: 1) + XCTAssertEqual(collection.numberOfItems(inSection: 0), count) + XCTAssertFalse(model.followsTail) + } + private func eventually(_ condition: () -> Bool) async throws { for _ in 0..<150 { if condition() { return } @@ -157,6 +225,8 @@ final class ToolTranscriptPresentationTests: XCTestCase { } private struct ToolTranscriptHTTP: HTTPPerforming { + var proposedPlan: String? = nil + func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { let url = request.url! guard url.lastPathComponent == "messages" else { @@ -199,6 +269,13 @@ private struct ToolTranscriptHTTP: HTTPPerforming { tool("Bash", input: ["command": "rm ios/Packages/HapiKit/Package.resolved && git diff --check && git status --short"]) tool("exec", input: ["code": "await tools.mcp__hapi__display_image({path: '/tmp/light-summary.png'})"]) tool("mcp__hapi__display_image", input: ["path": "/tmp/light-summary.png"]) + if let proposedPlan { + append(["type": "tool-call", "name": "ExitPlanMode", "callId": "proposal", "input": ["plan": .string(proposedPlan)]]) + append(["type": "tool-call-result", "callId": "proposal", "output": .null]) + for index in 0..<20 { + append(["type": "message", "id": .string("after-plan-\(index)"), "message": .string("Later message \(index)\n\nKeeps the plan away from the live tail for recycling tests.")]) + } + } append(["type": "message", "message": "检查完成,可以继续对话。"]) return rows } diff --git a/ios/README.md b/ios/README.md index 31ea7997..6b5cc67c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -104,6 +104,13 @@ TEST_RUNNER_HAPI_TYPOGRAPHY_CAPTURE=/tmp/hapi-typography-review \ ### Tool inspection +Plan proposals (`ExitPlanMode` / `exit_plan_mode`) are reading documents, not +activity summaries: their complete `input.plan` Markdown stays visible in the +conversation, before any approval controls. The same renderer is used in the +inspector; null output does not show a misleading "No output" placeholder. +Plans are prewarmed in the chat Markdown cache and never use the ordinary +tool-output preview/paging budget. The inspector retains raw fields under Source. + Tool summaries open a native large sheet instead of expanding their output inside the conversation. Tool groups also stay as one summary row: tapping one opens a native lazy list in the same inspector. Calls remain chronological, with diff --git a/shared/fixtures/chat/codex-plan-proposal-completed.json b/shared/fixtures/chat/codex-plan-proposal-completed.json new file mode 100644 index 00000000..76aef9fa --- /dev/null +++ b/shared/fixtures/chat/codex-plan-proposal-completed.json @@ -0,0 +1,224 @@ +{ + "description": "Proposal Markdown lives in input.plan. Keep it standalone between ordinary tools; null output completes a proposal without inventing approval. The lowercase alias also accepts a pending permission.", + "expected": { + "blocks": [ + { + "createdAt": 1755000000000, + "id": "read-before", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-before", + "input": { + "file_path": "/repo/README.md" + }, + "name": "Read", + "result": "Readme", + "state": "completed" + } + }, + { + "createdAt": 1755000002000, + "id": "codex-proposed-plan:plan-0", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "codex-proposed-plan:plan-0", + "input": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "name": "ExitPlanMode", + "result": null, + "state": "completed" + } + }, + { + "createdAt": 1755000004000, + "id": "read-after", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-after", + "input": { + "file_path": "/repo/package.json" + }, + "name": "Read", + "result": "{}", + "state": "completed" + } + } + ], + "hasReadyEvent": false, + "latestUsage": null, + "visibleBlocks": [ + { + "createdAt": 1755000000000, + "id": "read-before", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-before", + "input": { + "file_path": "/repo/README.md" + }, + "name": "Read", + "result": "Readme", + "state": "completed" + } + }, + { + "createdAt": 1755000002000, + "id": "codex-proposed-plan:plan-0", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "codex-proposed-plan:plan-0", + "input": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "name": "ExitPlanMode", + "result": null, + "state": "completed" + } + }, + { + "createdAt": 1755000004000, + "id": "read-after", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-after", + "input": { + "file_path": "/repo/package.json" + }, + "name": "Read", + "result": "{}", + "state": "completed" + } + } + ] + }, + "fixtureVersion": 1, + "input": { + "agentState": null, + "messages": [ + { + "content": { + "content": { + "data": { + "callId": "read-before", + "input": { + "file_path": "/repo/README.md" + }, + "name": "Read", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000000000, + "id": "proposal-0-0", + "localId": null, + "seq": 1 + }, + { + "content": { + "content": { + "data": { + "callId": "read-before", + "output": "Readme", + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000001000, + "id": "proposal-0-1", + "localId": null, + "seq": 2 + }, + { + "content": { + "content": { + "data": { + "callId": "codex-proposed-plan:plan-0", + "id": "plan-0", + "input": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "name": "ExitPlanMode", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000002000, + "id": "proposal-0-2", + "localId": null, + "seq": 3 + }, + { + "content": { + "content": { + "data": { + "callId": "codex-proposed-plan:plan-0", + "id": "plan-0:result", + "output": null, + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000003000, + "id": "proposal-0-3", + "localId": null, + "seq": 4 + }, + { + "content": { + "content": { + "data": { + "callId": "read-after", + "input": { + "file_path": "/repo/package.json" + }, + "name": "Read", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000004000, + "id": "proposal-0-4", + "localId": null, + "seq": 5 + }, + { + "content": { + "content": { + "data": { + "callId": "read-after", + "output": "{}", + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000005000, + "id": "proposal-0-5", + "localId": null, + "seq": 6 + } + ], + "options": { + "hasMoreMessages": false + } + }, + "name": "codex-plan-proposal-completed" +} diff --git a/shared/fixtures/chat/codex-plan-proposal-pending.json b/shared/fixtures/chat/codex-plan-proposal-pending.json new file mode 100644 index 00000000..940919b5 --- /dev/null +++ b/shared/fixtures/chat/codex-plan-proposal-pending.json @@ -0,0 +1,221 @@ +{ + "description": "Proposal Markdown lives in input.plan. Keep it standalone between ordinary tools; null output completes a proposal without inventing approval. The lowercase alias also accepts a pending permission.", + "expected": { + "blocks": [ + { + "createdAt": 1755000000000, + "id": "read-before", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-before", + "input": { + "file_path": "/repo/README.md" + }, + "name": "Read", + "result": "Readme", + "state": "completed" + } + }, + { + "createdAt": 1755000002000, + "id": "codex-proposed-plan:plan-1", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "codex-proposed-plan:plan-1", + "input": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "name": "exit_plan_mode", + "permission": { + "status": "pending" + }, + "state": "running" + } + }, + { + "createdAt": 1755000003000, + "id": "read-after", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-after", + "input": { + "file_path": "/repo/package.json" + }, + "name": "Read", + "result": "{}", + "state": "completed" + } + } + ], + "hasReadyEvent": false, + "latestUsage": null, + "visibleBlocks": [ + { + "createdAt": 1755000000000, + "id": "read-before", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-before", + "input": { + "file_path": "/repo/README.md" + }, + "name": "Read", + "result": "Readme", + "state": "completed" + } + }, + { + "createdAt": 1755000002000, + "id": "codex-proposed-plan:plan-1", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "codex-proposed-plan:plan-1", + "input": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "name": "exit_plan_mode", + "permission": { + "status": "pending" + }, + "state": "running" + } + }, + { + "createdAt": 1755000003000, + "id": "read-after", + "kind": "tool-call", + "localId": null, + "tool": { + "id": "read-after", + "input": { + "file_path": "/repo/package.json" + }, + "name": "Read", + "result": "{}", + "state": "completed" + } + } + ] + }, + "fixtureVersion": 1, + "input": { + "agentState": { + "requests": { + "plan-approval": { + "arguments": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "createdAt": 1755000002000, + "tool": "exit_plan_mode", + "toolCallId": "codex-proposed-plan:plan-1" + } + } + }, + "messages": [ + { + "content": { + "content": { + "data": { + "callId": "read-before", + "input": { + "file_path": "/repo/README.md" + }, + "name": "Read", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000000000, + "id": "proposal-1-0", + "localId": null, + "seq": 1 + }, + { + "content": { + "content": { + "data": { + "callId": "read-before", + "output": "Readme", + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000001000, + "id": "proposal-1-1", + "localId": null, + "seq": 2 + }, + { + "content": { + "content": { + "data": { + "callId": "codex-proposed-plan:plan-1", + "id": "plan-1", + "input": { + "plan": "# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n" + }, + "name": "exit_plan_mode", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000002000, + "id": "proposal-1-2", + "localId": null, + "seq": 3 + }, + { + "content": { + "content": { + "data": { + "callId": "read-after", + "input": { + "file_path": "/repo/package.json" + }, + "name": "Read", + "type": "tool-call" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000003000, + "id": "proposal-1-3", + "localId": null, + "seq": 4 + }, + { + "content": { + "content": { + "data": { + "callId": "read-after", + "output": "{}", + "type": "tool-call-result" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1755000004000, + "id": "proposal-1-4", + "localId": null, + "seq": 5 + } + ], + "options": { + "hasMoreMessages": false + } + }, + "name": "codex-plan-proposal-pending" +} diff --git a/web/scripts/fixtures/cases/codex.ts b/web/scripts/fixtures/cases/codex.ts index 71f4740e..4b38c979 100644 --- a/web/scripts/fixtures/cases/codex.ts +++ b/web/scripts/fixtures/cases/codex.ts @@ -7,6 +7,30 @@ import { T0, wireMessage } from './support' * stream snapshots keyed by data.id. */ export const codexCases: FixtureCase[] = [ + ...(['ExitPlanMode', 'exit_plan_mode'] as const).map((name, index): FixtureCase => { + const plan = '# 实施计划\n\n1. Inspect **input.plan**\n2. Render Markdown\n\n```swift\nlet ready = true\n```\n' + const callId = `codex-proposed-plan:plan-${index}` + const pending = index === 1 + const data = [ + { type: 'tool-call', name: 'Read', callId: 'read-before', input: { file_path: '/repo/README.md' } }, + { type: 'tool-call-result', callId: 'read-before', output: 'Readme' }, + { type: 'tool-call', name, callId, input: { plan }, id: `plan-${index}` }, + ...(!pending ? [{ type: 'tool-call-result', callId, output: null, id: `plan-${index}:result` }] : []), + { type: 'tool-call', name: 'Read', callId: 'read-after', input: { file_path: '/repo/package.json' } }, + { type: 'tool-call-result', callId: 'read-after', output: '{}' } + ] + return { + name: pending ? 'codex-plan-proposal-pending' : 'codex-plan-proposal-completed', + description: 'Proposal Markdown lives in input.plan. Keep it standalone between ordinary tools; null output completes a proposal without inventing approval. The lowercase alias also accepts a pending permission.', + messages: data.map((entry, seq) => wireMessage({ + id: `proposal-${index}-${seq}`, seq: seq + 1, createdAt: T0 + seq * 1000, + content: { role: 'agent', content: { type: 'codex', data: entry } } + })), + ...(pending ? { agentState: { requests: { + 'plan-approval': { tool: name, toolCallId: callId, arguments: { plan }, createdAt: T0 + 2000 } + } } } : {}) + } + }), { name: 'codex-message-stream-snapshot', description: 'Codex family: two message payloads sharing a stream id (data.id) are cumulative snapshots. Expects a single agent-text block keyed by that stable stream id, carrying the final snapshot text.',