From d74c4fb234421a309e13047aa3636fcc8114f148 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 17:53:50 +0800 Subject: [PATCH] fix(tools): include trailing output in error briefs and render brief as plain text - Add ToolResultBuilder.tail() to extract last non-empty lines from output - Include tail output in Shell tool error briefs on non-zero exit - Include tail output in ACP Terminal tool error briefs on timeout/signal/error - Render Brief display blocks as plain text instead of Markdown --- CHANGELOG.md | 2 ++ docs/en/release-notes/changelog.md | 2 ++ docs/zh/release-notes/changelog.md | 2 ++ src/kimi_cli/acp/tools.py | 8 +++-- src/kimi_cli/tools/shell/__init__.py | 6 +++- src/kimi_cli/tools/utils.py | 20 ++++++++++++ src/kimi_cli/ui/shell/visualize/_blocks.py | 2 +- tests/utils/test_result_builder.py | 38 ++++++++++++++++++++++ 8 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73272051f..401edab2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Show trailing output in tool error briefs when commands fail + ## 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/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 2a7756172..6971dec1f 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Shell: Show trailing output in tool error briefs when commands fail + ## 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/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index a35291665..4e9595293 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Shell:工具执行失败时显示完整错误信息 + ## 1.45.0 (2026-05-26) - Shell:`/clear` 现在成为 `/new` 的别名——两者都会启动新会话;此前 `/clear` 仅清空上下文而不创建新会话 diff --git a/src/kimi_cli/acp/tools.py b/src/kimi_cli/acp/tools.py index 055c9edb3..c9c6aaa8e 100644 --- a/src/kimi_cli/acp/tools.py +++ b/src/kimi_cli/acp/tools.py @@ -142,20 +142,22 @@ async def __call__(self, params: ShellParams) -> ToolReturnValue: else "" ) + tail = builder.tail() + tail_md = f"\n{tail}" if tail else "" 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: diff --git a/src/kimi_cli/tools/shell/__init__.py b/src/kimi_cli/tools/shell/__init__.py index 790f425db..94d2056f2 100644 --- a/src/kimi_cli/tools/shell/__init__.py +++ b/src/kimi_cli/tools/shell/__init__.py @@ -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( f"Command failed with exit code: {exitcode}.", - brief=f"Failed with exit code: {exitcode}", + brief=brief, ) except TimeoutError: return builder.error( diff --git a/src/kimi_cli/tools/utils.py b/src/kimi_cli/tools/utils.py index 8427703a2..dc79a8120 100644 --- a/src/kimi_cli/tools/utils.py +++ b/src/kimi_cli/tools/utils.py @@ -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. + """ + collected: list[str] = [] + for chunk in reversed(self._buffer): + for line in reversed(chunk.splitlines()): + 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) diff --git a/src/kimi_cli/ui/shell/visualize/_blocks.py b/src/kimi_cli/ui/shell/visualize/_blocks.py index c840af6b3..6e57039b2 100644 --- a/src/kimi_cli/ui/shell/visualize/_blocks.py +++ b/src/kimi_cli/ui/shell/visualize/_blocks.py @@ -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) diff --git a/tests/utils/test_result_builder.py b/tests/utils/test_result_builder.py index 34f87ade9..7d456ba6d 100644 --- a/tests/utils/test_result_builder.py +++ b/tests/utils/test_result_builder.py @@ -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"