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.tool_hint_len > 0:
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.tool_hint_len = len(suffix)
buf.sequence += 1

View File

@ -30,21 +30,26 @@ def format_tool_hints(tool_calls: list) -> str:
if not tool_calls:
return ""
hints = []
for name, count, example_tc in _group_consecutive(tool_calls):
fmt = _TOOL_FORMATS.get(name)
formatted = []
for tc in tool_calls:
fmt = _TOOL_FORMATS.get(tc.name)
if fmt:
hint = _fmt_known(example_tc, fmt)
elif name.startswith("mcp_"):
hint = _fmt_mcp(example_tc)
formatted.append(_fmt_known(tc, fmt))
elif tc.name.startswith("mcp_"):
formatted.append(_fmt_mcp(tc))
else:
hint = _fmt_fallback(example_tc)
formatted.append(_fmt_fallback(tc))
if count > 1:
hint = f"{hint} \u00d7 {count}"
hints.append(hint)
hints = []
for hint in formatted:
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:

View File

@ -136,22 +136,30 @@ class TestToolHintFolding:
result = _hint(calls)
assert "\u00d7" not in result
def test_two_consecutive_same_folded(self):
def test_two_consecutive_different_args_not_folded(self):
calls = [
_tc("grep", {"pattern": "*.py"}),
_tc("grep", {"pattern": "*.ts"}),
]
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
def test_three_consecutive_same_folded(self):
def test_three_consecutive_different_args_not_folded(self):
calls = [
_tc("read_file", {"path": "a.py"}),
_tc("read_file", {"path": "b.py"}),
_tc("read_file", {"path": "c.py"}),
]
result = _hint(calls)
assert "\u00d7 3" in result
assert "\u00d7" not in result
def test_different_tools_not_folded(self):
calls = [
@ -218,7 +226,7 @@ class TestToolHintMixedFolding:
"""G4: Mixed folding groups with interleaved same-tool segments."""
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 = [
_tc("read_file", {"path": "a.py"}),
_tc("read_file", {"path": "b.py"}),
@ -227,7 +235,6 @@ class TestToolHintMixedFolding:
_tc("read_file", {"path": "c.py"}),
]
result = _hint(calls)
assert "\u00d7 2" in result
# Should have 3 groups: read×2, grep×2, read
assert "\u00d7" not in result
parts = result.split(", ")
assert len(parts) == 3
assert len(parts) == 5

View File

@ -309,7 +309,7 @@ class TestToolHintInlineStreaming:
await ch.send(msg)
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.sequence == 3
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):
"""When new delta arrives, the tool hint is kept as permanent content and delta appends after it."""
ch = _make_channel()
suffix = "\n\n---\n🔧 web_fetch(\"url\")"
suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix,
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("$ git status") == 1
assert buf.text.startswith("Partial answer")
assert buf.text.endswith("🔧 $ git status")
assert "🔧 $ git status" in buf.text
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_resuming_flush(self):
"""When _resuming flushes the buffer, tool hint is kept as permanent content."""
ch = _make_channel()
suffix = "\n\n---\n🔧 $ cd /project"
suffix = "\n\n🔧 $ cd /project\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix,
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):
"""When final _stream_end closes the card, tool hint is kept in the final text."""
ch = _make_channel()
suffix = "\n\n---\n🔧 web_fetch(\"url\")"
suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content" + suffix,
card_id="card_1", sequence=3, last_edit=0.0,