fix(tool-hints): deduplicate by formatted string + per-line inline display

Two display fixes based on real-world Feishu testing:

1. tool_hints.py: format_tool_hints now deduplicates by comparing the
   fully formatted hint string instead of tool name alone. This fixes
   `ls /Desktop` and `ls /Downloads` being incorrectly merged as
   `ls /Desktop × 2`. Truly identical calls still fold correctly.
   (_group_consecutive and all abbreviation logic preserved unchanged.)

2. feishu.py: inline tool hints now display one tool per line with
   🔧 prefix, and use double-newline trailing to prevent Setext heading
   rendering when followed by markdown `---`.

Made-with: Cursor
This commit is contained in:
xzq.xu 2026-04-08 13:12:47 +08:00 committed by chengyongru
parent 586d4e2411
commit dcc9c057bb
4 changed files with 38 additions and 24 deletions

View File

@ -1379,7 +1379,9 @@ class FeishuChannel(BaseChannel):
if buf and buf.card_id: if buf and buf.card_id:
if buf.tool_hint_len > 0: if buf.tool_hint_len > 0:
buf.text = buf.text[:-buf.tool_hint_len] buf.text = buf.text[:-buf.tool_hint_len]
suffix = f"\n\n---\n🔧 {hint}" lines = self._format_tool_hint_lines(hint).split("\n")
formatted = "\n".join(f"🔧 {ln}" for ln in lines if ln.strip())
suffix = f"\n\n{formatted}\n\n"
buf.text += suffix buf.text += suffix
buf.tool_hint_len = len(suffix) buf.tool_hint_len = len(suffix)
buf.sequence += 1 buf.sequence += 1

View File

@ -30,21 +30,26 @@ def format_tool_hints(tool_calls: list) -> str:
if not tool_calls: if not tool_calls:
return "" return ""
hints = [] formatted = []
for name, count, example_tc in _group_consecutive(tool_calls): for tc in tool_calls:
fmt = _TOOL_FORMATS.get(name) fmt = _TOOL_FORMATS.get(tc.name)
if fmt: if fmt:
hint = _fmt_known(example_tc, fmt) formatted.append(_fmt_known(tc, fmt))
elif name.startswith("mcp_"): elif tc.name.startswith("mcp_"):
hint = _fmt_mcp(example_tc) formatted.append(_fmt_mcp(tc))
else: else:
hint = _fmt_fallback(example_tc) formatted.append(_fmt_fallback(tc))
if count > 1: hints = []
hint = f"{hint} \u00d7 {count}" for hint in formatted:
hints.append(hint) if hints and hints[-1][0] == hint:
hints[-1] = (hint, hints[-1][1] + 1)
else:
hints.append((hint, 1))
return ", ".join(hints) return ", ".join(
f"{h} \u00d7 {c}" if c > 1 else h for h, c in hints
)
def _get_args(tc) -> dict: def _get_args(tc) -> dict:

View File

@ -136,22 +136,30 @@ class TestToolHintFolding:
result = _hint(calls) result = _hint(calls)
assert "\u00d7" not in result assert "\u00d7" not in result
def test_two_consecutive_same_folded(self): def test_two_consecutive_different_args_not_folded(self):
calls = [ calls = [
_tc("grep", {"pattern": "*.py"}), _tc("grep", {"pattern": "*.py"}),
_tc("grep", {"pattern": "*.ts"}), _tc("grep", {"pattern": "*.ts"}),
] ]
result = _hint(calls) result = _hint(calls)
assert "\u00d7" not in result
def test_two_consecutive_same_args_folded(self):
calls = [
_tc("grep", {"pattern": "TODO"}),
_tc("grep", {"pattern": "TODO"}),
]
result = _hint(calls)
assert "\u00d7 2" in result assert "\u00d7 2" in result
def test_three_consecutive_same_folded(self): def test_three_consecutive_different_args_not_folded(self):
calls = [ calls = [
_tc("read_file", {"path": "a.py"}), _tc("read_file", {"path": "a.py"}),
_tc("read_file", {"path": "b.py"}), _tc("read_file", {"path": "b.py"}),
_tc("read_file", {"path": "c.py"}), _tc("read_file", {"path": "c.py"}),
] ]
result = _hint(calls) result = _hint(calls)
assert "\u00d7 3" in result assert "\u00d7" not in result
def test_different_tools_not_folded(self): def test_different_tools_not_folded(self):
calls = [ calls = [
@ -218,7 +226,7 @@ class TestToolHintMixedFolding:
"""G4: Mixed folding groups with interleaved same-tool segments.""" """G4: Mixed folding groups with interleaved same-tool segments."""
def test_read_read_grep_grep_read(self): def test_read_read_grep_grep_read(self):
"""read×2, grep×2, read — should produce two separate groups.""" """All different args — each hint listed separately."""
calls = [ calls = [
_tc("read_file", {"path": "a.py"}), _tc("read_file", {"path": "a.py"}),
_tc("read_file", {"path": "b.py"}), _tc("read_file", {"path": "b.py"}),
@ -227,7 +235,6 @@ class TestToolHintMixedFolding:
_tc("read_file", {"path": "c.py"}), _tc("read_file", {"path": "c.py"}),
] ]
result = _hint(calls) result = _hint(calls)
assert "\u00d7 2" in result assert "\u00d7" not in result
# Should have 3 groups: read×2, grep×2, read
parts = result.split(", ") parts = result.split(", ")
assert len(parts) == 3 assert len(parts) == 5

View File

@ -309,7 +309,7 @@ class TestToolHintInlineStreaming:
await ch.send(msg) await ch.send(msg)
buf = ch._stream_bufs["oc_chat1"] buf = ch._stream_bufs["oc_chat1"]
assert buf.text.endswith('🔧 web_fetch("https://example.com")') assert '🔧 web_fetch("https://example.com")' in buf.text
assert buf.tool_hint_len > 0 assert buf.tool_hint_len > 0
assert buf.sequence == 3 assert buf.sequence == 3
ch._client.cardkit.v1.card_element.content.assert_called_once() ch._client.cardkit.v1.card_element.content.assert_called_once()
@ -319,7 +319,7 @@ class TestToolHintInlineStreaming:
async def test_tool_hint_preserved_on_next_delta(self): async def test_tool_hint_preserved_on_next_delta(self):
"""When new delta arrives, the tool hint is kept as permanent content and delta appends after it.""" """When new delta arrives, the tool hint is kept as permanent content and delta appends after it."""
ch = _make_channel() ch = _make_channel()
suffix = "\n\n---\n🔧 web_fetch(\"url\")" suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix, text="Partial answer" + suffix,
card_id="card_1", sequence=3, last_edit=0.0, card_id="card_1", sequence=3, last_edit=0.0,
@ -376,13 +376,13 @@ class TestToolHintInlineStreaming:
assert buf.text.count("$ cd /project") == 0 assert buf.text.count("$ cd /project") == 0
assert buf.text.count("$ git status") == 1 assert buf.text.count("$ git status") == 1
assert buf.text.startswith("Partial answer") assert buf.text.startswith("Partial answer")
assert buf.text.endswith("🔧 $ git status") assert "🔧 $ git status" in buf.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_hint_preserved_on_resuming_flush(self): async def test_tool_hint_preserved_on_resuming_flush(self):
"""When _resuming flushes the buffer, tool hint is kept as permanent content.""" """When _resuming flushes the buffer, tool hint is kept as permanent content."""
ch = _make_channel() ch = _make_channel()
suffix = "\n\n---\n🔧 $ cd /project" suffix = "\n\n🔧 $ cd /project\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix, text="Partial answer" + suffix,
card_id="card_1", sequence=2, last_edit=0.0, card_id="card_1", sequence=2, last_edit=0.0,
@ -401,7 +401,7 @@ class TestToolHintInlineStreaming:
async def test_tool_hint_preserved_on_final_stream_end(self): async def test_tool_hint_preserved_on_final_stream_end(self):
"""When final _stream_end closes the card, tool hint is kept in the final text.""" """When final _stream_end closes the card, tool hint is kept in the final text."""
ch = _make_channel() ch = _make_channel()
suffix = "\n\n---\n🔧 web_fetch(\"url\")" suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content" + suffix, text="Final content" + suffix,
card_id="card_1", sequence=3, last_edit=0.0, card_id="card_1", sequence=3, last_edit=0.0,