Skip to content

Commit 861c67b

Browse files
Miss-youliruifengv
andauthored
fix(shell): echo workflow slash inputs (#2052)
Co-authored-by: Will (liruifengv) <liruifeng1024@gmail.com>
1 parent bfd9d27 commit 861c67b

5 files changed

Lines changed: 344 additions & 12 deletions

File tree

src/kimi_cli/ui/shell/__init__.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from kimi_cli.llm import model_display_name
2828
from kimi_cli.notifications import NotificationManager, NotificationWatcher
2929
from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul
30-
from kimi_cli.soul.kimisoul import KimiSoul
30+
from kimi_cli.soul.kimisoul import FLOW_COMMAND_PREFIX, KimiSoul
3131
from kimi_cli.ui.shell import update as _update_mod
3232
from kimi_cli.ui.shell.console import console
3333
from kimi_cli.ui.shell.echo import render_user_echo_text
@@ -41,8 +41,8 @@
4141
toast,
4242
)
4343
from kimi_cli.ui.shell.replay import replay_recent_history
44+
from kimi_cli.ui.shell.slash import SKILL_COMMAND_PREFIX, shell_mode_registry
4445
from kimi_cli.ui.shell.slash import registry as shell_slash_registry
45-
from kimi_cli.ui.shell.slash import shell_mode_registry
4646
from kimi_cli.ui.shell.update import LATEST_VERSION_FILE, UpdateResult, do_update, semver_tuple
4747
from kimi_cli.ui.shell.visualize import (
4848
ApprovalPromptDelegate,
@@ -76,6 +76,9 @@ class _PromptEvent:
7676
_BG_AUTO_TRIGGER_INPUT_GRACE_S = 0.75
7777
"""Delay background auto-trigger briefly after local prompt activity."""
7878

79+
_VISIBLE_WORKFLOW_SLASH_PREFIXES = (SKILL_COMMAND_PREFIX, FLOW_COMMAND_PREFIX)
80+
"""Explicit skill/flow prefixes that should remain visible in transcript."""
81+
7982

8083
class _BackgroundCompletionWatcher:
8184
"""Watches for background task completions and auto-triggers the agent.
@@ -256,11 +259,23 @@ def _agent_slash_command_call(user_input: UserInput) -> SlashCommandCall | None:
256259
return resolved_call
257260

258261
@staticmethod
259-
def _should_echo_agent_input(user_input: UserInput) -> bool:
262+
def _should_echo_workflow_slash_input(user_input: UserInput) -> bool:
263+
command_call = Shell._agent_slash_command_call(user_input)
264+
return command_call is not None and command_call.name.startswith(
265+
_VISIBLE_WORKFLOW_SLASH_PREFIXES
266+
)
267+
268+
def _should_echo_agent_input(self, user_input: UserInput) -> bool:
260269
if user_input.mode != PromptMode.AGENT:
261270
return False
262271
if Shell._should_exit_input(user_input):
263272
return False
273+
# Phase 1 policy: keep operational slash commands hidden, but show
274+
# explicit `/skill:*` and `/flow:*` inputs because they represent
275+
# user-visible workflow intent and otherwise vanish from transcript
276+
# even when the command later fails to resolve.
277+
if self._should_echo_workflow_slash_input(user_input):
278+
return True
264279
return Shell._agent_slash_command_call(user_input) is None
265280

266281
@staticmethod

tests/core/test_kimisoul_slash_commands.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4+
from unittest.mock import AsyncMock
45

6+
import pytest
57
from kaos.path import KaosPath
68
from kosong.tooling.empty import EmptyToolset
79

10+
import kimi_cli.soul.kimisoul as kimisoul_module
811
from kimi_cli.skill import Skill
912
from kimi_cli.skill.flow import Flow, FlowEdge, FlowNode
1013
from kimi_cli.soul.agent import Agent, Runtime
1114
from kimi_cli.soul.context import Context
1215
from kimi_cli.soul.kimisoul import KimiSoul
16+
from kimi_cli.utils.slashcmd import SlashCommand
1317

1418

1519
def _make_flow() -> Flow:
@@ -51,3 +55,72 @@ def test_flow_skill_registers_skill_and_flow_commands(runtime: Runtime, tmp_path
5155
command_names = {cmd.name for cmd in soul.available_slash_commands}
5256
assert "skill:flow-skill" in command_names
5357
assert "flow:flow-skill" in command_names
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_skill_slash_run_does_not_auto_generate_session_title(
62+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
63+
) -> None:
64+
skill_dir = tmp_path / "demo-skill"
65+
skill_dir.mkdir()
66+
skill_dir.joinpath("SKILL.md").write_text(
67+
"\n".join(
68+
[
69+
"---",
70+
"name: demo-skill",
71+
"description: Demo skill",
72+
"---",
73+
"",
74+
"Use this skill for tests.",
75+
]
76+
),
77+
encoding="utf-8",
78+
)
79+
runtime.skills = {
80+
"demo-skill": Skill(
81+
name="demo-skill",
82+
description="Demo skill",
83+
type="standard",
84+
dir=KaosPath.unsafe_from_local_path(skill_dir),
85+
skill_md_file=KaosPath.unsafe_from_local_path(skill_dir / "SKILL.md"),
86+
scope="user",
87+
)
88+
}
89+
90+
agent = Agent(
91+
name="Test Agent",
92+
system_prompt="Test system prompt.",
93+
toolset=EmptyToolset(),
94+
runtime=runtime,
95+
)
96+
soul = KimiSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl"))
97+
soul._turn = AsyncMock(return_value=None) # type: ignore[method-assign]
98+
monkeypatch.setattr(kimisoul_module, "wire_send", lambda _msg: None)
99+
100+
await soul.run("/skill:demo-skill fix login")
101+
102+
assert runtime.session.state.custom_title is None
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_flow_slash_run_does_not_auto_generate_session_title(
107+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
108+
) -> None:
109+
agent = Agent(
110+
name="Test Agent",
111+
system_prompt="Test system prompt.",
112+
toolset=EmptyToolset(),
113+
runtime=runtime,
114+
)
115+
soul = KimiSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl"))
116+
soul._slash_command_map["flow:demo-flow"] = SlashCommand(
117+
name="flow:demo-flow",
118+
description="Demo flow",
119+
func=lambda *_args, **_kwargs: None,
120+
aliases=[],
121+
)
122+
monkeypatch.setattr(kimisoul_module, "wire_send", lambda _msg: None)
123+
124+
await soul.run("/flow:demo-flow")
125+
126+
assert runtime.session.state.custom_title is None

tests/ui_and_conv/test_shell_prompt_echo.py

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
from types import SimpleNamespace
2+
from typing import cast
3+
14
from kosong.message import Message
25
from rich.text import Text
36

47
import kimi_cli.ui.shell as shell_module
8+
from kimi_cli.soul import Soul
59
from kimi_cli.ui.shell import Shell
610
from kimi_cli.ui.shell.echo import render_user_echo
711
from kimi_cli.ui.shell.prompt import PromptMode, UserInput
8-
from kimi_cli.utils.slashcmd import SlashCommandCall
12+
from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall
913
from kimi_cli.wire.types import AudioURLPart, ImageURLPart, TextPart, VideoURLPart
1014

1115

@@ -18,6 +22,23 @@ def _make_user_input(command: str, *, mode: PromptMode = PromptMode.AGENT) -> Us
1822
)
1923

2024

25+
def _noop(app: object, args: str) -> None:
26+
pass
27+
28+
29+
def _make_shell(*command_names: str) -> Shell:
30+
commands = [
31+
SlashCommand(
32+
name=name,
33+
description=f"{name} command",
34+
func=_noop,
35+
aliases=[],
36+
)
37+
for name in command_names
38+
]
39+
return Shell(cast(Soul, SimpleNamespace(available_slash_commands=commands)))
40+
41+
2142
def test_echo_agent_input_prints_stringified_user_message(monkeypatch) -> None:
2243
printed: list[Text] = []
2344
monkeypatch.setattr(shell_module.console, "print", lambda text: printed.append(text))
@@ -105,16 +126,48 @@ def test_render_user_echo_preserves_mixed_content_order() -> None:
105126

106127

107128
def test_should_echo_agent_input_for_plain_agent_message() -> None:
108-
assert Shell._should_echo_agent_input(_make_user_input("hi")) is True
129+
shell = _make_shell()
130+
assert shell._should_echo_agent_input(_make_user_input("hi")) is True
131+
132+
133+
def test_should_not_echo_agent_input_for_exit_commands() -> None:
134+
shell = _make_shell()
135+
assert shell._should_echo_agent_input(_make_user_input("exit")) is False
136+
assert shell._should_echo_agent_input(_make_user_input("/exit")) is False
137+
assert shell._should_echo_agent_input(_make_user_input("/quit")) is False
138+
109139

140+
def test_should_echo_agent_input_for_skill_slash_commands() -> None:
141+
shell = _make_shell("skill:demo")
142+
assert shell._should_echo_agent_input(_make_user_input("/skill:demo")) is True
143+
assert shell._should_echo_agent_input(_make_user_input("/skill:demo 修一下登录")) is True
110144

111-
def test_should_not_echo_agent_input_for_exit_or_slash_commands() -> None:
112-
assert Shell._should_echo_agent_input(_make_user_input("exit")) is False
113-
assert Shell._should_echo_agent_input(_make_user_input("/exit")) is False
114-
assert Shell._should_echo_agent_input(_make_user_input("/help")) is False
145+
146+
def test_should_echo_unregistered_skill_like_slash_commands() -> None:
147+
shell = _make_shell("skill:demo")
148+
assert shell._should_echo_agent_input(_make_user_input("/skill:not-found")) is True
149+
150+
151+
def test_should_echo_flow_slash_commands() -> None:
152+
shell = _make_shell("flow:demo")
153+
assert shell._should_echo_agent_input(_make_user_input("/flow:demo")) is True
154+
assert shell._should_echo_agent_input(_make_user_input("/flow:demo 执行一下")) is True
155+
156+
157+
def test_should_echo_unregistered_flow_like_slash_commands() -> None:
158+
shell = _make_shell("flow:demo")
159+
assert shell._should_echo_agent_input(_make_user_input("/flow:not-found")) is True
160+
161+
162+
def test_should_not_echo_agent_input_for_non_skill_slash_commands() -> None:
163+
shell = _make_shell("skill:demo")
164+
assert shell._should_echo_agent_input(_make_user_input("/help")) is False
165+
assert shell._should_echo_agent_input(_make_user_input("/theme dark")) is False
166+
assert shell._should_echo_agent_input(_make_user_input("/clear")) is False
115167

116168

117169
def test_hidden_slash_in_placeholder_is_not_treated_as_local_command() -> None:
170+
shell = _make_shell()
118171
user_input = UserInput(
119172
mode=PromptMode.AGENT,
120173
command="[Pasted text #1 +3 lines]",
@@ -124,7 +177,7 @@ def test_hidden_slash_in_placeholder_is_not_treated_as_local_command() -> None:
124177

125178
assert Shell._should_exit_input(user_input) is False
126179
assert Shell._agent_slash_command_call(user_input) is None
127-
assert Shell._should_echo_agent_input(user_input) is True
180+
assert shell._should_echo_agent_input(user_input) is True
128181

129182

130183
def test_should_exit_input_is_mode_independent_for_visible_exit_commands() -> None:
@@ -135,6 +188,7 @@ def test_should_exit_input_is_mode_independent_for_visible_exit_commands() -> No
135188

136189

137190
def test_visible_slash_command_keeps_expanded_placeholder_args() -> None:
191+
shell = _make_shell()
138192
user_input = UserInput(
139193
mode=PromptMode.AGENT,
140194
command="/echo [Pasted text #1 +3 lines]",
@@ -147,8 +201,43 @@ def test_visible_slash_command_keeps_expanded_placeholder_args() -> None:
147201
args="line1\nline2\nline3",
148202
raw_input="/echo line1\nline2\nline3",
149203
)
150-
assert Shell._should_echo_agent_input(user_input) is False
204+
assert shell._should_echo_agent_input(user_input) is False
205+
206+
207+
def test_visible_skill_slash_command_keeps_expanded_placeholder_args_and_still_echoes() -> None:
208+
shell = _make_shell("skill:demo")
209+
user_input = UserInput(
210+
mode=PromptMode.AGENT,
211+
command="/skill:demo [Pasted text #1 +3 lines]",
212+
resolved_command="/skill:demo line1\nline2\nline3",
213+
content=[TextPart(text="line1\nline2\nline3")],
214+
)
215+
216+
assert Shell._agent_slash_command_call(user_input) == SlashCommandCall(
217+
name="skill:demo",
218+
args="line1\nline2\nline3",
219+
raw_input="/skill:demo line1\nline2\nline3",
220+
)
221+
assert shell._should_echo_agent_input(user_input) is True
222+
223+
224+
def test_visible_flow_slash_command_keeps_expanded_placeholder_args_and_still_echoes() -> None:
225+
shell = _make_shell("flow:demo")
226+
user_input = UserInput(
227+
mode=PromptMode.AGENT,
228+
command="/flow:demo [Pasted text #1 +3 lines]",
229+
resolved_command="/flow:demo line1\nline2\nline3",
230+
content=[TextPart(text="line1\nline2\nline3")],
231+
)
232+
233+
assert Shell._agent_slash_command_call(user_input) == SlashCommandCall(
234+
name="flow:demo",
235+
args="line1\nline2\nline3",
236+
raw_input="/flow:demo line1\nline2\nline3",
237+
)
238+
assert shell._should_echo_agent_input(user_input) is True
151239

152240

153241
def test_should_not_echo_non_agent_input() -> None:
154-
assert Shell._should_echo_agent_input(_make_user_input("ls", mode=PromptMode.SHELL)) is False
242+
shell = _make_shell()
243+
assert shell._should_echo_agent_input(_make_user_input("ls", mode=PromptMode.SHELL)) is False

0 commit comments

Comments
 (0)