feat: add session context export/import slash commands with shell utilities and tests#1317
feat: add session context export/import slash commands with shell utilities and tests#1317
Conversation
- Implemented the /export command to allow users to export the current session context to a markdown file. - Implemented the /import command to enable users to import context from a file or session ID. - Added utility functions for building export markdown and validating importable file types. - Created tests for the new commands and utility functions to ensure functionality and correctness. - Updated path utility to sanitize CLI paths for better handling of user input.
merge from main
There was a problem hiding this comment.
Pull request overview
Adds session context export/import support to Kimi CLI via new /export and /import slash commands, including markdown export formatting utilities, CLI path sanitization, and new tests to validate export/import serialization and wire protocol command discovery.
Changes:
- Register
/exportand/importin both soul-level and shell-level slash command flows. - Add
kimi_cli.utils.exportfor export markdown generation and import transcript stringification + importable-file filtering. - Add
sanitize_cli_path()and expand path/export-related test coverage and wire protocol expectations.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests_e2e/test_wire_protocol.py | Updates initialize handshake expectations to include export/import slash commands. |
| tests/utils/test_utils_path.py | Adds unit tests for sanitize_cli_path() behavior (quotes/whitespace). |
| tests/ui_and_conv/test_export_import.py | Adds comprehensive unit tests for export/import helper functions (formatting, grouping, filtering). |
| src/kimi_cli/utils/path.py | Introduces sanitize_cli_path() for normalizing quoted CLI path args. |
| src/kimi_cli/utils/export.py | New export/import utility module (markdown export + import transcript + extension allowlist). |
| src/kimi_cli/ui/shell/slash.py | Exposes ensure_kimi_soul() and imports new export_import module to register commands. |
| src/kimi_cli/ui/shell/export_import.py | Implements shell-side /export + /import commands (file IO, session lookup, wire markers). |
| src/kimi_cli/soul/slash.py | Adds soul-level /export + /import commands and applies sanitize_cli_path() to /add-dir. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| """File extensions accepted by ``/import``. Only text-based formats are | ||
| supported — importing binary files (images, PDFs, archives, …) is rejected | ||
| with a friendly message.""" |
There was a problem hiding this comment.
Same issue as above: the triple-quoted text after _IMPORTABLE_EXTENSIONS is a standalone string literal (no-op). Please change it to a comment so it doesn’t look like a misplaced docstring / trigger “pointless statement” lint rules.
| """File extensions accepted by ``/import``. Only text-based formats are | |
| supported — importing binary files (images, PDFs, archives, …) is rejected | |
| with a friendly message.""" | |
| # File extensions accepted by ``/import``. Only text-based formats are | |
| # supported — importing binary files (images, PDFs, archives, …) is rejected | |
| # with a friendly message. |
|
|
||
| target_path = Path(target).expanduser() | ||
|
|
||
| if target_path.exists() and target_path.is_file(): |
There was a problem hiding this comment.
/import treats an existing directory path as a session-id lookup (because it only checks exists() and is_file()), which leads to a confusing “not a valid file path or session ID” error for directories. Add an explicit if target_path.exists() and target_path.is_dir(): branch with a clearer message (e.g., “path is a directory; please provide a file”).
| if target_path.exists() and target_path.is_file(): | |
| if target_path.exists() and target_path.is_dir(): | |
| console.print( | |
| "[red]The specified path is a directory; please provide a file to import.[/red]" | |
| ) | |
| return | |
| elif target_path.exists() and target_path.is_file(): |
| @registry.command(name="import") | ||
| async def import_context(app: Shell, args: str): | ||
| """Import context from a file or session ID""" | ||
| soul = ensure_kimi_soul(app) | ||
| if soul is None: | ||
| return | ||
|
|
||
| target = sanitize_cli_path(args) | ||
| if not target: | ||
| console.print("[yellow]Usage: /import <file_path or session_id>[/yellow]") | ||
| return | ||
|
|
||
| target_path = Path(target).expanduser() | ||
|
|
||
| if target_path.exists() and target_path.is_file(): | ||
| # Check file extension | ||
| if not is_importable_file(target_path.name): | ||
| console.print( | ||
| f"[red]Unsupported file type '{target_path.suffix}'. " | ||
| "/import only supports text-based files " | ||
| "(e.g. .md, .txt, .json, .py, .log, …).[/red]" | ||
| ) | ||
| return | ||
|
|
||
| # Import from file | ||
| try: | ||
| async with aiofiles.open(target_path, encoding="utf-8") as f: | ||
| content = await f.read() | ||
| except UnicodeDecodeError: | ||
| console.print( | ||
| f"[red]Cannot import '{target_path.name}': " |
There was a problem hiding this comment.
The /export and /import implementations here largely duplicate the logic in src/kimi_cli/soul/slash.py (path resolution, import-from-file/session, message construction). To reduce drift over time, consider extracting shared helpers (e.g., “resolve_import_source”, “build_import_message”, “resolve_export_path”) into kimi_cli.utils.export and reusing them from both entry points.
src/kimi_cli/soul/slash.py
Outdated
| from kaos.path import KaosPath | ||
|
|
||
| from kimi_cli.utils.path import is_within_directory, list_directory | ||
| from kimi_cli.utils.path import is_within_directory, list_directory, sanitize_cli_path |
There was a problem hiding this comment.
Inside add_dir, sanitize_cli_path is imported again even though it’s already imported at module scope and used elsewhere in this file. Removing it from the inner import list would avoid redundant imports and keep the import list focused on the local-only dependencies.
| from kimi_cli.utils.path import is_within_directory, list_directory, sanitize_cli_path | |
| from kimi_cli.utils.path import is_within_directory, list_directory |
src/kimi_cli/soul/slash.py
Outdated
| target_path = Path(target).expanduser() | ||
|
|
||
| if target_path.exists() and target_path.is_file(): | ||
| # Check file extension | ||
| from kimi_cli.utils.export import is_importable_file | ||
|
|
||
| if not is_importable_file(target_path.name): | ||
| wire_send( |
There was a problem hiding this comment.
Same as the shell handler: an existing directory path falls through to the session-id lookup branch because only exists() and is_file() is checked. Add an explicit is_dir() branch to return a clearer error for directory inputs.
src/kimi_cli/soul/slash.py
Outdated
| # Build and append import message | ||
| import_text = f'<imported_context source="{source_desc}">\n{content}\n</imported_context>' | ||
| message = Message( | ||
| role="user", | ||
| content=[ | ||
| system( | ||
| f"The user has imported context from {source_desc}. " | ||
| "This is a prior conversation history that may be relevant " | ||
| "to the current session. " | ||
| "Please review this context and use it to inform your responses." | ||
| ), | ||
| TextPart(text=import_text), | ||
| ], |
There was a problem hiding this comment.
source_desc is interpolated into an XML-like attribute value (source="..."). If the value contains " or & (e.g., unusual file names), the tag becomes malformed and may confuse the model. Escape/encode the attribute value or avoid attributes and include the source as plain text inside the block.
| _HINT_KEYS = ("path", "file_path", "command", "query", "url", "name", "pattern") | ||
| """Common tool-call argument keys whose values make good one-line hints.""" |
There was a problem hiding this comment.
The triple-quoted string on the line after _HINT_KEYS = ... is a standalone string literal (a no-op statement), not an actual docstring, and will be flagged by some linters as a pointless statement. Convert it to a # comment or incorporate the explanation into a module docstring/comment block.
| _HINT_KEYS = ("path", "file_path", "command", "query", "url", "name", "pattern") | |
| """Common tool-call argument keys whose values make good one-line hints.""" | |
| # Common tool-call argument keys whose values make good one-line hints. | |
| _HINT_KEYS = ("path", "file_path", "command", "query", "url", "name", "pattern") |
|
|
||
|
|
||
| def _group_into_turns(history: Sequence[Message]) -> list[list[Message]]: | ||
| """Group messages into logical turns, each starting at a real user message.""" |
There was a problem hiding this comment.
_group_into_turns docstring says each turn starts at a real user message, but the implementation intentionally groups leading system/developer messages into their own initial turn (see behavior in this loop). Please update the docstring to match the actual grouping semantics so future changes don’t “fix” this accidentally.
| """Group messages into logical turns, each starting at a real user message.""" | |
| """Group messages into logical turns. | |
| Any leading non-user messages (e.g. system/developer) before the first | |
| real user message are grouped into an initial turn. Each subsequent turn | |
| starts at a user message and includes all following messages up to (but | |
| not including) the next user message or the end of the history. Internal | |
| checkpoint messages are skipped entirely. | |
| """ |
| # Build the import message | ||
| import_text = f'<imported_context source="{source_desc}">\n{content}\n</imported_context>' | ||
|
|
There was a problem hiding this comment.
source_desc is interpolated into an XML-like attribute value (source="..."). If the file name or session id contains a double quote (or &), this will produce malformed markup and potentially confuse downstream consumers. Consider escaping attribute values (e.g., replace "/&) or avoid attributes entirely (put the source on its own line inside the tag).
| @registry.command | ||
| async def export(app: Shell, args: str): | ||
| """Export current session context to a markdown file""" | ||
| soul = ensure_kimi_soul(app) | ||
| if soul is None: | ||
| return | ||
|
|
||
| context = soul.context | ||
| history = list(context.history) # snapshot to avoid concurrent mutation |
There was a problem hiding this comment.
This new module adds significant user-facing behavior (file IO, path resolution, and context mutation) for /export and /import, but there don’t appear to be tests covering the shell-command behavior end-to-end (e.g., /export creates the expected file in a temp dir; /import from a file/session appends the expected Message and writes the TurnBegin/TurnEnd markers). Adding a small set of shell-level command tests would help prevent regressions.
Signed-off-by: Kai <me@kaiyi.cool>
Signed-off-by: Kai <me@kaiyi.cool>
… in resolve_import_source
…ust datetime handling
…n and add tests for session restore and export edge cases
…nd update warnings in import context
Signed-off-by: Kai <me@kaiyi.cool>
… context handling
| source_desc, content_len = result | ||
| wire_send(TextPart(text=f"Imported context from {source_desc} ({content_len} chars).")) |
There was a problem hiding this comment.
🟡 Soul-level /import handler missing wire_file writes, breaking session replay
The shell-level /import handler in export_import.py:97-101 explicitly writes TurnBegin and TurnEnd to soul.wire_file so the import appears in session replay. However, the soul-level /import handler in soul/slash.py:205-243 does not write to wire_file. When /import is invoked through the wire protocol (non-shell UIs), the import action will not be recorded in the session's wire file and won't appear during session replay.
Root Cause
The shell handler at src/kimi_cli/ui/shell/export_import.py:97-101 writes wire markers:
await soul.wire_file.append_message(
TurnBegin(user_input=f"[Imported context from {source_desc}]")
)
await soul.wire_file.append_message(TurnEnd())But the soul-level handler at src/kimi_cli/soul/slash.py:205-243 only uses wire_send() (which sends events over the live wire protocol) and never writes to wire_file (which persists events for session replay). KimiSoul.run() at src/kimi_cli/soul/kimisoul.py:241 does call wire_send(TurnBegin(...)) but this only sends events to the wire transport — it does not write to the wire_file for replay.
Impact: When using /import in wire mode (or any non-shell UI), the import won't be visible if the session is later replayed or resumed.
Was this helpful? React with 👍 or 👎 to provide feedback.
This PR introduces session context export/import support via slash commands.
What changed
/exportand/importcommand handling in soul/shell slash command flow.Scope
Checklist
make gen-changelogto update the changelog.make gen-docsto update the user documentation.