From aa47175f4695ee7539423fc65a6811ea9a7c22e1 Mon Sep 17 00:00:00 2001 From: Pluviobyte Date: Thu, 28 May 2026 07:51:20 +0000 Subject: [PATCH 1/2] fix(shell): persist pasted text placeholders Store long pasted text through the prompt attachment cache so placeholder tokens can be resolved after history recall creates a fresh PromptPlaceholderManager. Keep legacy [Pasted text #n] tokens readable while preventing unresolved pasted text placeholders from being sent to the model verbatim; missing payloads now resolve to an explicit error message. Fixes #1946 Co-authored-by: Cursor --- CHANGELOG.md | 2 + src/kimi_cli/ui/shell/placeholders.py | 86 ++++++++++++++++--- tests/ui_and_conv/test_prompt_clipboard.py | 18 +++- tests/ui_and_conv/test_prompt_history.py | 10 +-- tests/ui_and_conv/test_prompt_placeholders.py | 41 +++++++-- 5 files changed, 131 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73272051f..c4e61fe60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Long pasted-text placeholders are now persisted in the prompt cache, so history recall or a restarted prompt session sends the original pasted content instead of the literal `[Pasted text ...]` token + ## 1.45.0 (2026-05-26) - Shell: `/clear` is now an alias for `/new` — both commands start a new session; previously `/clear` only cleared context without creating a new session diff --git a/src/kimi_cli/ui/shell/placeholders.py b/src/kimi_cli/ui/shell/placeholders.py index f58c39c84..8e9bb2782 100644 --- a/src/kimi_cli/ui/shell/placeholders.py +++ b/src/kimi_cli/ui/shell/placeholders.py @@ -30,6 +30,10 @@ _PASTED_TEXT_PLACEHOLDER_RE = re.compile( r"\[Pasted text #(?P\d+)(?: \+(?P\d+) lines?)?\]" ) +_PERSISTED_PASTED_TEXT_PLACEHOLDER_RE = re.compile( + r"\[Pasted text:(?P[a-zA-Z0-9_\-\.]+)(?: \+(?P\d+) lines?)?\]" +) +_MISSING_PASTED_TEXT_MESSAGE = "[Missing pasted text: original placeholder content is unavailable]" _TEXT_PASTE_CHAR_THRESHOLD = get_env_int("KIMI_CLI_PASTE_CHAR_THRESHOLD", 1000) _TEXT_PASTE_LINE_THRESHOLD = get_env_int("KIMI_CLI_PASTE_LINE_THRESHOLD", 15) @@ -72,6 +76,13 @@ def build_pasted_text_placeholder(paste_id: int, text: str) -> str: return f"[Pasted text #{paste_id} +{line_count} lines]" +def build_persisted_pasted_text_placeholder(attachment_id: str, text: str) -> str: + line_count = count_text_lines(text) + if line_count <= 1: + return f"[Pasted text:{attachment_id}]" + return f"[Pasted text:{attachment_id} +{line_count} lines]" + + def _guess_image_mime(path: Path) -> str: mime, _ = mimetypes.guess_type(path.name) if mime: @@ -88,7 +99,7 @@ def _build_image_part(image_bytes: bytes, mime_type: str) -> ImageURLPart: ) -type CachedAttachmentKind = Literal["image"] +type CachedAttachmentKind = Literal["image", "pasted_text"] @dataclass(slots=True) @@ -109,7 +120,10 @@ def __init__( ) -> None: self._root = root or _DEFAULT_PROMPT_CACHE_ROOT self._legacy_roots = tuple(legacy_roots or (_LEGACY_PROMPT_CACHE_ROOT,)) - self._dir_map: dict[CachedAttachmentKind, str] = {"image": "images"} + self._dir_map: dict[CachedAttachmentKind, str] = { + "image": "images", + "pasted_text": "pasted-text", + } self._payload_map: dict[tuple[CachedAttachmentKind, str, str], CachedAttachment] = {} def _dir_for(self, kind: CachedAttachmentKind, *, root: Path | None = None) -> Path: @@ -171,6 +185,9 @@ def store_image(self, image: Image.Image) -> CachedAttachment | None: image.save(png_bytes, format="PNG") return self.store_bytes("image", ".png", png_bytes.getvalue()) + def store_pasted_text(self, text: str) -> CachedAttachment | None: + return self.store_bytes("pasted_text", ".txt", text.encode("utf-8")) + def _candidate_paths(self, kind: CachedAttachmentKind, attachment_id: str) -> list[Path]: roots = (self._root, *self._legacy_roots) return [self._dir_for(kind, root=root) / attachment_id for root in roots] @@ -205,6 +222,13 @@ def load_content_parts( return wrap_media_part(part, tag="image", attrs={"path": str(path)}) return None + def load_pasted_text(self, attachment_id: str) -> str | None: + payload = self.load_bytes("pasted_text", attachment_id) + if payload is None: + return None + _path, text_bytes = payload + return text_bytes.decode("utf-8", errors="replace") + def parse_attachment_kind(raw_kind: str) -> CachedAttachmentKind | None: if raw_kind == "image": @@ -246,13 +270,24 @@ def token(self) -> str: return build_pasted_text_placeholder(self.paste_id, self.text) +@dataclass(slots=True) +class ResolvedPastedTextEntry: + text: str + token: str + + class PastedTextPlaceholderHandler: - def __init__(self) -> None: + def __init__(self, attachment_cache: AttachmentCache) -> None: + self._attachment_cache = attachment_cache self._entries: dict[int, PastedTextEntry] = {} self._next_id = 1 def create_placeholder(self, text: str) -> str: normalized = sanitize_surrogates(normalize_pasted_text(text)) + cached = self._attachment_cache.store_pasted_text(normalized) + if cached is not None: + return build_persisted_pasted_text_placeholder(cached.attachment_id, normalized) + entry = PastedTextEntry(paste_id=self._next_id, text=normalized) self._entries[entry.paste_id] = entry self._next_id += 1 @@ -269,19 +304,18 @@ def entry_for_id(self, paste_id: int) -> PastedTextEntry | None: def iter_entries_for_command( self, command: str - ) -> list[tuple[PlaceholderTokenMatch, PastedTextEntry]]: - entries: list[tuple[PlaceholderTokenMatch, PastedTextEntry]] = [] + ) -> list[tuple[PlaceholderTokenMatch, ResolvedPastedTextEntry]]: + entries: list[tuple[PlaceholderTokenMatch, ResolvedPastedTextEntry]] = [] cursor = 0 while match := self.find_next(command, cursor): - paste_id = int(match.match.group("id")) - entry = self.entry_for_id(paste_id) + entry = self._resolved_entry_for_match(match) if entry is not None: entries.append((match, entry)) cursor = match.end return entries def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: - match = _PASTED_TEXT_PLACEHOLDER_RE.search(text, start) + match = self._find_earliest_text_match(text, start) if match is None: return None return PlaceholderTokenMatch( @@ -293,18 +327,30 @@ def find_next(self, text: str, start: int = 0) -> PlaceholderTokenMatch | None: ) def resolve_content(self, match: PlaceholderTokenMatch) -> list[ContentPart] | None: - paste_id = int(match.match.group("id")) - entry = self.entry_for_id(paste_id) - if entry is None: - return None - return [TextPart(text=entry.text)] + return [TextPart(text=self.expand_text(match))] def expand_text(self, match: PlaceholderTokenMatch) -> str | None: + return self._text_for_match(match) or _MISSING_PASTED_TEXT_MESSAGE + + def _text_for_match(self, match: PlaceholderTokenMatch) -> str | None: + if match.match.re is _PERSISTED_PASTED_TEXT_PLACEHOLDER_RE: + return self._attachment_cache.load_pasted_text(match.match.group("id")) + paste_id = int(match.match.group("id")) entry = self.entry_for_id(paste_id) return None if entry is None else entry.text + def _resolved_entry_for_match( + self, match: PlaceholderTokenMatch + ) -> ResolvedPastedTextEntry | None: + text = self._text_for_match(match) + if text is None: + return None + return ResolvedPastedTextEntry(text=text, token=match.raw) + def serialize_for_history(self, match: PlaceholderTokenMatch) -> str | None: + if match.match.re is _PERSISTED_PASTED_TEXT_PLACEHOLDER_RE: + return match.raw return self.expand_text(match) def expand_for_editor(self, match: PlaceholderTokenMatch) -> str | None: @@ -335,6 +381,18 @@ def refold_after_editor(self, edited_text: str, original_command: str) -> str: result = result[:start] + token + result[end:] return result + @staticmethod + def _find_earliest_text_match(text: str, start: int) -> re.Match[str] | None: + legacy_match = _PASTED_TEXT_PLACEHOLDER_RE.search(text, start) + persisted_match = _PERSISTED_PASTED_TEXT_PLACEHOLDER_RE.search(text, start) + if legacy_match is None: + return persisted_match + if persisted_match is None: + return legacy_match + if persisted_match.start() < legacy_match.start(): + return persisted_match + return legacy_match + def _expanded_text_and_intervals( self, command: str ) -> tuple[str, list[tuple[int, int, str, str]]]: @@ -435,7 +493,7 @@ class ResolvedPromptCommand: class PromptPlaceholderManager: def __init__(self, attachment_cache: AttachmentCache | None = None) -> None: self._attachment_cache = attachment_cache or AttachmentCache() - self._text_handler = PastedTextPlaceholderHandler() + self._text_handler = PastedTextPlaceholderHandler(self._attachment_cache) self._image_handler = ImagePlaceholderHandler(self._attachment_cache) self._handlers: tuple[PlaceholderHandler, ...] = ( self._text_handler, diff --git a/tests/ui_and_conv/test_prompt_clipboard.py b/tests/ui_and_conv/test_prompt_clipboard.py index adebfe7b7..a5f975b53 100644 --- a/tests/ui_and_conv/test_prompt_clipboard.py +++ b/tests/ui_and_conv/test_prompt_clipboard.py @@ -37,10 +37,24 @@ def invalidate(self) -> None: class _FakeAttachmentCache(shell_prompt.AttachmentCache): def __init__(self, store_result: shell_prompt.CachedAttachment | None) -> None: self.store_result = store_result + self.stored_text: str | None = None def store_image(self, image: Image.Image) -> shell_prompt.CachedAttachment | None: return self.store_result + def store_pasted_text(self, text: str) -> shell_prompt.CachedAttachment | None: + self.stored_text = text + return shell_prompt.CachedAttachment( + kind="pasted_text", + attachment_id="paste123.txt", + path=Path("/tmp/paste123.txt"), + ) + + def load_pasted_text(self, attachment_id: str) -> str | None: + if attachment_id == "paste123.txt": + return self.stored_text + return None + def _make_prompt_session( mode: PromptMode, *, supports_image: bool = True @@ -279,7 +293,7 @@ def test_insert_pasted_text_placeholderizes_long_text_in_agent_mode() -> None: assert len(buffer.inserted) == 1 inserted = buffer.inserted[0] - assert inserted == "[Pasted text #1 +15 lines]" + assert inserted == "[Pasted text:paste123.txt +15 lines]" user_input = ps._build_user_input(inserted) assert user_input.command == inserted @@ -321,7 +335,7 @@ def test_handle_bracketed_paste_placeholderizes_long_text_in_agent_mode() -> Non ps._handle_bracketed_paste(cast(KeyPressEvent, event)) - assert buffer.inserted == ["[Pasted text #1 +15 lines]"] + assert buffer.inserted == ["[Pasted text:paste123.txt +15 lines]"] assert app.invalidated is True resolved_text = "\n".join([f"line{i}" for i in range(1, 16)]) user_input = ps._build_user_input(buffer.inserted[0]) diff --git a/tests/ui_and_conv/test_prompt_history.py b/tests/ui_and_conv/test_prompt_history.py index 27cbf88c5..41c297751 100644 --- a/tests/ui_and_conv/test_prompt_history.py +++ b/tests/ui_and_conv/test_prompt_history.py @@ -23,7 +23,7 @@ def _read_history_lines(path) -> list[dict[str, str]]: return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] -def test_append_history_entry_expands_text_placeholders_but_preserves_images(tmp_path) -> None: +def test_append_history_entry_preserves_persistent_placeholders(tmp_path) -> None: manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path / "cache")) pasted_text = "\n".join([f"line{i}" for i in range(1, 16)]) text_token = manager.maybe_placeholderize_pasted_text(pasted_text) @@ -36,7 +36,7 @@ def test_append_history_entry_expands_text_placeholders_but_preserves_images(tmp prompt_session._append_history_entry(f"before {text_token} {image_token} after") assert _read_history_lines(prompt_session._history_file) == [ - {"content": f"before {pasted_text} {image_token} after"} + {"content": f"before {text_token} {image_token} after"} ] @@ -51,7 +51,7 @@ def test_append_history_entry_deduplicates_consecutive_tokens_with_same_expanded prompt_session._append_history_entry(token_one) prompt_session._append_history_entry(token_two) - assert _read_history_lines(prompt_session._history_file) == [{"content": "alpha\nbeta\ngamma"}] + assert _read_history_lines(prompt_session._history_file) == [{"content": token_one}] def test_append_history_entry_writes_sanitized_surrogate_text(tmp_path) -> None: @@ -64,5 +64,5 @@ def test_append_history_entry_writes_sanitized_surrogate_text(tmp_path) -> None: lines = _read_history_lines(prompt_session._history_file) assert len(lines) == 1 assert "\ud83d" not in lines[0]["content"] - assert "\ufffd" in lines[0]["content"] - assert lines[0]["content"].startswith("A" * 1000) + assert "\ufffd" not in lines[0]["content"] + assert lines[0]["content"] == token diff --git a/tests/ui_and_conv/test_prompt_placeholders.py b/tests/ui_and_conv/test_prompt_placeholders.py index 3fb6e7a8f..c172adf5a 100644 --- a/tests/ui_and_conv/test_prompt_placeholders.py +++ b/tests/ui_and_conv/test_prompt_placeholders.py @@ -11,6 +11,11 @@ from kimi_cli.wire.types import ImageURLPart, TextPart +def _assert_pasted_text_token(token: str, line_count: int) -> None: + assert token.startswith("[Pasted text:") + assert token.endswith(f" +{line_count} lines]") + + def test_placeholder_manager_serializes_text_tokens_for_history(tmp_path) -> None: manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path)) text_token = manager.maybe_placeholderize_pasted_text("alpha\nbeta\ngamma") @@ -21,7 +26,7 @@ def test_placeholder_manager_serializes_text_tokens_for_history(tmp_path) -> Non history_text = manager.serialize_for_history(f"before {text_token} {image_token} after") - assert history_text == f"before alpha\nbeta\ngamma {image_token} after" + assert history_text == f"before {text_token} {image_token} after" def test_placeholder_manager_refolds_editor_text_for_known_text_tokens() -> None: @@ -87,13 +92,14 @@ def test_placeholder_manager_only_refolds_unedited_placeholder_when_multiple_exi assert refolded == f"{first_token}\n---\none\ntwo changed\nthree" -def test_placeholder_manager_leaves_unknown_text_token_literal() -> None: +def test_placeholder_manager_uses_error_text_for_unknown_legacy_text_token() -> None: manager = PromptPlaceholderManager() resolved = manager.resolve_command("[Pasted text #999 +3 lines]") - assert resolved.resolved_text == "[Pasted text #999 +3 lines]" - assert resolved.content == [TextPart(text="[Pasted text #999 +3 lines]")] + assert "[Pasted text #999 +3 lines]" not in resolved.resolved_text + assert "Missing pasted text" in resolved.resolved_text + assert resolved.content == [TextPart(text=resolved.resolved_text)] def test_placeholder_manager_resolves_mixed_text_and_image_tokens(tmp_path) -> None: @@ -117,6 +123,31 @@ def test_placeholder_manager_resolves_mixed_text_and_image_tokens(tmp_path) -> N assert resolved.content[5] == TextPart(text="") +def test_placeholder_manager_resolves_pasted_text_with_new_manager(tmp_path) -> None: + cache = AttachmentCache(root=tmp_path) + manager = PromptPlaceholderManager(attachment_cache=cache) + pasted_text = "\n".join([f"line{i}" for i in range(1, 16)]) + text_token = manager.maybe_placeholderize_pasted_text(pasted_text) + _assert_pasted_text_token(text_token, 15) + + new_manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path)) + resolved = new_manager.resolve_command(f"review {text_token}") + + assert resolved.resolved_text == f"review {pasted_text}" + assert resolved.content == [TextPart(text="review "), TextPart(text=pasted_text)] + + +def test_placeholder_manager_uses_error_text_for_missing_persisted_text(tmp_path) -> None: + manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path)) + token = "[Pasted text:missing.txt +15 lines]" + + resolved = manager.resolve_command(token) + + assert token not in resolved.resolved_text + assert "Missing pasted text" in resolved.resolved_text + assert resolved.content == [TextPart(text=resolved.resolved_text)] + + def test_placeholder_manager_expands_text_but_not_image_for_editor(tmp_path) -> None: manager = PromptPlaceholderManager(attachment_cache=AttachmentCache(root=tmp_path)) text_token = manager.maybe_placeholderize_pasted_text("alpha\nbeta\ngamma") @@ -170,7 +201,7 @@ def test_placeholder_manager_normalizes_crlf_before_threshold_and_resolution() - lines = "\r\n".join([f"line{i}" for i in range(1, 16)]) token = manager.maybe_placeholderize_pasted_text(lines) - assert token == "[Pasted text #1 +15 lines]" + _assert_pasted_text_token(token, 15) resolved = manager.resolve_command(token) assert resolved.resolved_text == "\n".join([f"line{i}" for i in range(1, 16)]) From 35a23fd14d5e07e0e65a23c5a0228be46ed65791 Mon Sep 17 00:00:00 2001 From: Pluviobyte Date: Sun, 31 May 2026 00:49:28 +0800 Subject: [PATCH 2/2] fix(shell): expand persisted pasted text in approval/question modals The approval and question modal delegates passed serialize_for_history as their text_expander, which keeps cache-backed pasted-text tokens folded. After the prompt-cache change, reject feedback and "Other" answers that contained long pastes were submitted to the model as the literal [Pasted text: ...] token instead of the original content. Wire both delegates to expand_for_editor, which always expands legacy and persisted pasted-text placeholders while leaving image tokens intact, and keep serialize_for_history for actual history serialization. Add modal regression tests covering persisted tokens and update the placeholder manager stubs. Co-authored-by: Cursor --- CHANGELOG.md | 2 +- src/kimi_cli/ui/shell/__init__.py | 2 +- .../ui/shell/visualize/_interactive.py | 2 +- tests/ui_and_conv/test_modal_lifecycle.py | 6 +- tests/ui_and_conv/test_prompt_clipboard.py | 110 ++++++++++++++++++ tests/ui_and_conv/test_shell_task_slash.py | 6 +- 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e61fe60..0d9034b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased -- Shell: Long pasted-text placeholders are now persisted in the prompt cache, so history recall or a restarted prompt session sends the original pasted content instead of the literal `[Pasted text ...]` token +- Shell: Long pasted-text placeholders are now persisted in the prompt cache, so history recall, a restarted prompt session, or pasting into approval/question prompt feedback sends the original pasted content instead of the literal `[Pasted text ...]` token ## 1.45.0 (2026-05-26) diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index e029eaaac..2cb330a83 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -1396,7 +1396,7 @@ def _activate_prompt_approval_modal(self) -> None: current_request, on_response=self._handle_prompt_approval_response, buffer_state_provider=self._get_default_buffer_text_and_cursor, - text_expander=self._prompt_session._get_placeholder_manager().serialize_for_history, # pyright: ignore[reportPrivateUsage] + text_expander=self._prompt_session._get_placeholder_manager().expand_for_editor, # pyright: ignore[reportPrivateUsage] ) self._prompt_session.attach_modal(self._approval_modal) else: diff --git a/src/kimi_cli/ui/shell/visualize/_interactive.py b/src/kimi_cli/ui/shell/visualize/_interactive.py index 90058e46a..ee43de2a0 100644 --- a/src/kimi_cli/ui/shell/visualize/_interactive.py +++ b/src/kimi_cli/ui/shell/visualize/_interactive.py @@ -511,7 +511,7 @@ def _on_question_panel_state_changed(self) -> None: on_advance=self._advance_question, on_invalidate=self._flush_prompt_refresh, buffer_text_provider=lambda: self._prompt_session._session.default_buffer.text, # pyright: ignore[reportPrivateUsage] - text_expander=self._prompt_session._get_placeholder_manager().serialize_for_history, # pyright: ignore[reportPrivateUsage] + text_expander=self._prompt_session._get_placeholder_manager().expand_for_editor, # pyright: ignore[reportPrivateUsage] ) self._prompt_session.attach_modal(self._question_modal) else: diff --git a/tests/ui_and_conv/test_modal_lifecycle.py b/tests/ui_and_conv/test_modal_lifecycle.py index 75e42f893..9e75b3a43 100644 --- a/tests/ui_and_conv/test_modal_lifecycle.py +++ b/tests/ui_and_conv/test_modal_lifecycle.py @@ -29,12 +29,16 @@ class _FakePlaceholderManager: - """Minimal placeholder manager stub — serialize_for_history is identity.""" + """Minimal placeholder manager stub — expansion helpers are identity.""" @staticmethod def serialize_for_history(text: str) -> str: return text + @staticmethod + def expand_for_editor(text: str) -> str: + return text + def _make_approval_request(request_id: str = "req-1", **kwargs: Any) -> ApprovalRequest: defaults = { diff --git a/tests/ui_and_conv/test_prompt_clipboard.py b/tests/ui_and_conv/test_prompt_clipboard.py index a5f975b53..2c9559f5d 100644 --- a/tests/ui_and_conv/test_prompt_clipboard.py +++ b/tests/ui_and_conv/test_prompt_clipboard.py @@ -405,3 +405,113 @@ def capture_submit_other(text: str) -> bool: assert expand_calls[0] == "prefix [Pasted text #1 +19 lines]" assert len(submitted_texts) == 1 assert full_text in submitted_texts[0] + + +async def test_question_delegate_expands_persisted_pasted_text() -> None: + """Cache-backed pasted-text tokens submitted via the question 'Other' field + must reach the model as full text. + + The modal wires the manager's ``expand_for_editor`` callback. Using + ``serialize_for_history`` would leave persisted tokens folded and send + ``[Pasted text:...]`` to the model verbatim.""" + from unittest.mock import patch + + from kimi_cli.ui.shell.placeholders import ( + PromptPlaceholderManager, + build_persisted_pasted_text_placeholder, + ) + from kimi_cli.ui.shell.visualize import QuestionPromptDelegate, QuestionRequestPanel + from kimi_cli.wire.types import QuestionItem, QuestionRequest + + full_text = "\n".join(f"line{i}" for i in range(1, 20)) + cache = _FakeAttachmentCache(None) + cached = cache.store_pasted_text(full_text) + assert cached is not None + manager = PromptPlaceholderManager(cache) + token = build_persisted_pasted_text_placeholder(cached.attachment_id, full_text) + + # Only expand_for_editor restores the payload; serialize_for_history keeps + # the compact token (correct for history, wrong for modal submission). + assert token == "[Pasted text:paste123.txt +19 lines]" + assert manager.serialize_for_history(token) == token + assert manager.expand_for_editor(token) == full_text + + question = QuestionItem(question="Review?", options=[], other_label="Revise") + request = QuestionRequest(id="q1", tool_call_id="tc1", questions=[question]) + panel = QuestionRequestPanel(request) + delegate = QuestionPromptDelegate( + panel, + on_advance=lambda: None, + on_invalidate=lambda: None, + text_expander=manager.expand_for_editor, + ) + panel.select_index(len(panel._options) - 1) + + submitted_texts: list[str] = [] + original_submit_other = panel.submit_other + + def capture_submit_other(text: str) -> bool: + submitted_texts.append(text) + return original_submit_other(text) + + buffer = _DummyBuffer() + buffer.text = f"prefix {token}" # type: ignore[attr-defined] + buffer.set_document = lambda *a, **kw: None # type: ignore[attr-defined] + + with patch.object(panel, "submit_other", capture_submit_other): + delegate._submit_other_input(cast("Buffer", buffer)) + + assert len(submitted_texts) == 1 + assert full_text in submitted_texts[0] + assert token not in submitted_texts[0] + + +async def test_approval_delegate_expands_persisted_pasted_text_feedback() -> None: + """Cache-backed pasted-text tokens typed into reject feedback must be + expanded to full text before reaching the model.""" + from prompt_toolkit.buffer import Buffer as PTBuffer + from prompt_toolkit.document import Document + + from kimi_cli.ui.shell.placeholders import ( + PromptPlaceholderManager, + build_persisted_pasted_text_placeholder, + ) + from kimi_cli.ui.shell.visualize import ApprovalPromptDelegate + from kimi_cli.wire.types import ApprovalRequest + + full_text = "\n".join(f"line{i}" for i in range(1, 20)) + cache = _FakeAttachmentCache(None) + cached = cache.store_pasted_text(full_text) + assert cached is not None + manager = PromptPlaceholderManager(cache) + token = build_persisted_pasted_text_placeholder(cached.attachment_id, full_text) + assert token == "[Pasted text:paste123.txt +19 lines]" + assert manager.serialize_for_history(token) == token + assert manager.expand_for_editor(token) == full_text + + request = ApprovalRequest( + id="req-1", + tool_call_id="call-1", + sender="Shell", + action="run command", + description="echo hello", + ) + buf = PTBuffer() + responses: list[tuple[str, str, str]] = [] + delegate = ApprovalPromptDelegate( + request, + on_response=lambda req, resp, feedback="": responses.append((req.id, resp, feedback)), + buffer_state_provider=lambda: (buf.text, buf.cursor_position), + text_expander=manager.expand_for_editor, + ) + delegate._panel.selected_index = 3 # "Reject + feedback" enables inline input + + feedback = f"please fix {token}" + buf.set_document(Document(text=feedback, cursor_position=len(feedback)), bypass_readonly=True) + event = type("_Event", (), {"current_buffer": buf})() + delegate.handle_running_prompt_key("enter", event) + + assert len(responses) == 1 + assert responses[0][1] == "reject" + assert full_text in responses[0][2] + assert token not in responses[0][2] diff --git a/tests/ui_and_conv/test_shell_task_slash.py b/tests/ui_and_conv/test_shell_task_slash.py index b9bc669dc..367df2bd3 100644 --- a/tests/ui_and_conv/test_shell_task_slash.py +++ b/tests/ui_and_conv/test_shell_task_slash.py @@ -21,12 +21,16 @@ class _FakePlaceholderManager: - """Minimal placeholder manager stub — serialize_for_history is identity.""" + """Minimal placeholder manager stub — expansion helpers are identity.""" @staticmethod def serialize_for_history(text: str) -> str: return text + @staticmethod + def expand_for_editor(text: str) -> str: + return text + def _make_shell_app(runtime: Runtime, tmp_path: Path) -> SimpleNamespace: agent = Agent(