Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Shell: Show trailing output in tool error briefs when commands fail
## 1.46.0 (2026-05-28)

- Shell: Support styled text in welcome tips
Expand Down
1 change: 1 addition & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Shell: Show trailing output in tool error briefs when commands fail
## 1.46.0 (2026-05-28)

- Shell: Support styled text in welcome tips
Expand Down
1 change: 1 addition & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 未发布

- Shell:工具执行失败时显示完整错误信息
## 1.46.0 (2026-05-28)

- Shell:欢迎提示支持样式化文本
Expand Down
8 changes: 5 additions & 3 deletions src/kimi_cli/acp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,22 @@ async def __call__(self, params: ShellParams) -> ToolReturnValue:
else ""
)

tail = builder.tail()
tail_md = f"\n{tail}" if tail else ""
Comment on lines +145 to +146
if timed_out:
return builder.error(
f"Command killed by timeout ({timeout_label}){truncated_note}",
brief=f"Killed by timeout ({timeout_label})",
brief=f"Killed by timeout ({timeout_label}){tail_md}",
)
if exit_signal:
return builder.error(
f"Command terminated by signal: {exit_signal}.{truncated_note}",
brief=f"Signal: {exit_signal}",
brief=f"Signal: {exit_signal}{tail_md}",
)
if exit_code not in (None, 0):
return builder.error(
f"Command failed with exit code: {exit_code}.{truncated_note}",
brief=f"Failed with exit code: {exit_code}",
brief=f"Failed with exit code: {exit_code}{tail_md}",
)
return builder.ok(f"Command executed successfully.{truncated_note}")
finally:
Expand Down
6 changes: 5 additions & 1 deletion src/kimi_cli/tools/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,13 @@ def stderr_cb(line: bytes):
if exitcode == 0:
return builder.ok("Command executed successfully.")
else:
brief = f"Failed with exit code: {exitcode}"
tail = builder.tail()
if tail:
brief += f"\n{tail}"
return builder.error(
Comment on lines +120 to 124
f"Command failed with exit code: {exitcode}.",
brief=f"Failed with exit code: {exitcode}",
brief=brief,
)
except TimeoutError:
return builder.error(
Comment on lines 128 to 129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Shell tool omits tail output in timeout error brief, inconsistent with ACP Terminal

The Shell tool only appends builder.tail() to the brief for non-zero exit code errors (lines 120-123), but omits it for TimeoutError (lines 128-132). Meanwhile, the ACP Terminal tool (src/kimi_cli/acp/tools.py:145-150) consistently includes the tail in all error cases including timeout. Before the TimeoutError is raised, stdout_cb/stderr_cb have already written output to the builder (via _run_shell_command at src/kimi_cli/tools/shell/__init__.py:247-254), so builder.tail() would contain useful context about what the command was doing before it timed out.

(Refers to lines 128-132)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down
20 changes: 20 additions & 0 deletions src/kimi_cli/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ def write(self, text: str) -> int:

return chars_written

def tail(self, max_lines: int = 5, max_line_len: int = 200) -> str:
"""Return the last non-empty lines from the buffer, joined with newlines.

Useful for surfacing actionable error context (stderr) in tool result briefs.
"""
Comment on lines +130 to +134
collected: list[str] = []
for chunk in reversed(self._buffer):
for line in reversed(chunk.splitlines()):
Comment on lines +136 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track tail output after truncation

For commands that emit more than the builder limit before failing (for example a verbose test run that prints >50 KB and then writes the actual failure at the end), write() has already stopped accepting new data, so scanning _buffer here returns the tail of the retained prefix rather than the command's real trailing stderr/stdout. Because the shell failure brief now displays this value, users can still miss the actionable error or see misleading context; keep a small rolling tail independently of the capped output buffer before using it in failure briefs.

Useful? React with 👍 / 👎.

stripped = line.rstrip()
if not stripped.strip():
continue
if len(stripped) > max_line_len:
stripped = stripped[:max_line_len] + "..."
collected.append(stripped)
if len(collected) >= max_lines:
break
if len(collected) >= max_lines:
break
return "\n".join(reversed(collected))

def display(self, *blocks: DisplayBlock) -> None:
"""Add display blocks to the tool result."""
self._display.extend(blocks)
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/ui/shell/visualize/_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ def _compose(self) -> RenderableType:
elif isinstance(block, BriefDisplayBlock):
style = "grey50" if not self._result.is_error else "dark_red"
if block.text:
lines.append(Markdown(block.text, style=style))
lines.append(Text(block.text.rstrip("\n"), style=style))
idx += 1
elif isinstance(block, TodoDisplayBlock):
markdown = self._render_todo_markdown(block)
Expand Down
38 changes: 38 additions & 0 deletions tests/utils/test_result_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,41 @@ def test_empty_write():
assert written == 0
assert builder.n_chars == 0
assert not builder.is_full


def test_tail_empty():
builder = ToolResultBuilder()
assert builder.tail() == ""


def test_tail_basic():
builder = ToolResultBuilder()
builder.write("first line\nsecond line\nthird line\n")
assert builder.tail() == "first line\nsecond line\nthird line"


def test_tail_skips_blank_lines():
builder = ToolResultBuilder()
builder.write("real error\n\n \n")
assert builder.tail() == "real error"


def test_tail_respects_max_lines():
builder = ToolResultBuilder()
builder.write("\n".join(f"line {i}" for i in range(10)) + "\n")
assert builder.tail(max_lines=3) == "line 7\nline 8\nline 9"


def test_tail_truncates_long_line():
builder = ToolResultBuilder()
builder.write("x" * 500 + "\n")
tail = builder.tail(max_line_len=100)
assert tail.endswith("...")
assert len(tail) == 103


def test_tail_handles_multiple_writes():
builder = ToolResultBuilder()
builder.write("stdout chunk\n")
builder.write("stderr: permission denied\n")
assert builder.tail(max_lines=2) == "stdout chunk\nstderr: permission denied"
Loading