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.',