Files
Memoh/internal/channel/toolcall_formatters_test.go
T
Acbox 473d559042 feat(channel): structured tool-call IM display with edit-in-place
Introduce a new `show_tool_calls_in_im` bot setting plus a full overhaul of
how tool calls are surfaced in IM channels:

- Add per-bot setting + migration (0072) and expose through settings API /
  handlers / frontend SDK.
- Introduce a `toolCallDroppingStream` wrapper that filters tool_call_* events
  when the setting is off, keeping the rest of the stream intact.
- Add a shared `ToolCallPresentation` model (Header / Body blocks / Footer)
  with plain and Markdown renderers, and a per-tool formatter registry that
  produces rich output (e.g. `web_search` link lists, `list` directory
  previews, `exec` stdout/stderr tails) instead of raw JSON dumps.
- High-capability adapters (Telegram, Feishu, Matrix, Slack, Discord) now
  flush pre-text and then send ONE tool-call message per call, editing it
  in-place from `running` to `completed` / `failed`; mapping from callID to
  platform message ID is tracked per stream, with a fallback to a new
  message if the edit fails. Low-capability adapters (WeCom, QQ, DingTalk)
  keep posting a single final message, but now benefit from the same rich
  per-tool formatting.
- Suppress the early duplicate `EventToolCallStart` (from
  `sdk.ToolInputStartPart`) so that the SDK's final `StreamToolCallPart`
  remains the single source of truth for tool call start, preventing
  duplicated "running" bubbles in IM.
- Stop auto-populating `InputSummary` / `ResultSummary` after a per-tool
  formatter runs, which previously leaked the raw JSON result as a
  fallback footer underneath the formatted body.

Add regression tests for the formatters, the Markdown renderer, the
edit-in-place flow on Telegram/Matrix, and the JSON-leak guard on `list`.
2026-04-23 20:49:44 +08:00

359 lines
9.7 KiB
Go

package channel
import (
"strings"
"testing"
)
func hasTextBlock(body []ToolCallBlock, needle string) bool {
for _, b := range body {
if b.Type != ToolCallBlockText {
continue
}
if strings.Contains(b.Text, needle) {
return true
}
}
return false
}
func hasLinkBlock(body []ToolCallBlock, url string) *ToolCallBlock {
for i := range body {
if body[i].Type != ToolCallBlockLink {
continue
}
if body[i].URL == url {
return &body[i]
}
}
return nil
}
func TestFormatListIncludesEntries(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "list",
Input: map[string]any{"path": "/var/log"},
Result: map[string]any{
"total_count": float64(12),
"entries": []any{
map[string]any{"path": "syslog", "is_dir": false, "size": float64(2300)},
map[string]any{"path": "auth.log", "is_dir": false, "size": float64(1100000)},
map[string]any{"path": "nginx", "is_dir": true},
},
},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Header, "/var/log") || !strings.Contains(p.Header, "12 entries") {
t.Fatalf("unexpected header: %q", p.Header)
}
if !hasTextBlock(p.Body, "syslog") {
t.Fatalf("expected syslog entry in body, got %+v", p.Body)
}
if !hasTextBlock(p.Body, "nginx/") {
t.Fatalf("expected nginx/ directory entry in body, got %+v", p.Body)
}
if !hasTextBlock(p.Body, "…and 9 more") {
t.Fatalf("expected ellipsis footer for remaining items, got %+v", p.Body)
}
if p.InputSummary != "" || p.ResultSummary != "" {
t.Fatalf("formatter output must not leak InputSummary/ResultSummary raw JSON, got in=%q res=%q", p.InputSummary, p.ResultSummary)
}
rendered := RenderToolCallMessage(p)
if strings.Contains(rendered, "\"entries\"") || strings.Contains(rendered, "{\"") {
t.Fatalf("rendered output leaked raw JSON result:\n%s", rendered)
}
}
func TestFormatExecForeground(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "exec",
Input: map[string]any{"command": "ls -la /tmp"},
Result: map[string]any{"exit_code": float64(0), "stdout": "total 16\nfoo bar"},
}
p := BuildToolCallEnd(tc)
if !strings.HasPrefix(p.Header, "$ ls -la /tmp") {
t.Fatalf("unexpected header: %q", p.Header)
}
if p.Footer != "exit=0" {
t.Fatalf("unexpected footer: %q", p.Footer)
}
if !hasTextBlock(p.Body, "stdout: total 16") {
t.Fatalf("expected stdout block, got %+v", p.Body)
}
}
func TestFormatExecBackground(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "exec",
Input: map[string]any{"command": "long_running.sh"},
Result: map[string]any{
"status": "background_started",
"task_id": "bg_abc",
"output_file": "/tmp/bg_abc.log",
},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Footer, "background_started") ||
!strings.Contains(p.Footer, "task_id=bg_abc") ||
!strings.Contains(p.Footer, "/tmp/bg_abc.log") {
t.Fatalf("unexpected footer: %q", p.Footer)
}
}
func TestFormatWebSearchEmitsLinks(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "web_search",
Input: map[string]any{"query": "golang generics tutorial"},
Result: map[string]any{
"results": []any{
map[string]any{
"title": "Tutorial: Getting started with generics",
"url": "https://go.dev/doc/tutorial/generics",
"description": "A comprehensive walkthrough",
},
map[string]any{
"title": "Go 1.18 is released",
"url": "https://go.dev/blog/go1.18",
},
},
},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Header, "2 results") || !strings.Contains(p.Header, `"golang generics tutorial"`) {
t.Fatalf("unexpected header: %q", p.Header)
}
link := hasLinkBlock(p.Body, "https://go.dev/doc/tutorial/generics")
if link == nil {
t.Fatalf("expected link block for tutorial, got %+v", p.Body)
}
if link.Title != "Tutorial: Getting started with generics" {
t.Fatalf("unexpected title: %q", link.Title)
}
if link.Desc != "A comprehensive walkthrough" {
t.Fatalf("unexpected desc: %q", link.Desc)
}
}
func TestFormatSendCarriesTargetAndBody(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "send",
Input: map[string]any{
"target": "chat:123",
"platform": "telegram",
"body": "Hello there",
},
Result: map[string]any{"delivered": "delivered", "message_id": "msg_456"},
}
p := BuildToolCallEnd(tc)
if p.Header != "→ chat:123 (telegram)" {
t.Fatalf("unexpected header: %q", p.Header)
}
if !hasTextBlock(p.Body, "Hello there") {
t.Fatalf("expected body text, got %+v", p.Body)
}
if !strings.Contains(p.Footer, "message_id=msg_456") {
t.Fatalf("unexpected footer: %q", p.Footer)
}
}
func TestFormatSendEmailCarriesSubject(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "send_email",
Input: map[string]any{
"to": "alice@example.com",
"subject": "Meeting notes",
},
Result: map[string]any{"status": "sent", "message_id": "mid1"},
}
p := BuildToolCallEnd(tc)
if p.Header != "→ alice@example.com" {
t.Fatalf("unexpected header: %q", p.Header)
}
if !hasTextBlock(p.Body, "Subject: Meeting notes") {
t.Fatalf("expected subject block, got %+v", p.Body)
}
if !strings.Contains(p.Footer, "status=sent") || !strings.Contains(p.Footer, "message_id=mid1") {
t.Fatalf("unexpected footer: %q", p.Footer)
}
}
func TestFormatSearchMemoryPrintsScoreAndTotal(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "search_memory",
Input: map[string]any{"query": "previous trip to Tokyo"},
Result: map[string]any{
"total": float64(10),
"results": []any{
map[string]any{"text": "Alice prefers sushi over ramen", "score": float64(0.91)},
map[string]any{"text": "Bob mentioned a trip to Tokyo in March", "score": float64(0.87)},
},
},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Header, "2 / 10 results") {
t.Fatalf("unexpected header: %q", p.Header)
}
if !hasTextBlock(p.Body, "(0.91)") {
t.Fatalf("expected score annotation, got %+v", p.Body)
}
}
func TestFormatSearchMessagesTruncatesPreview(t *testing.T) {
t.Parallel()
msgs := make([]any, 0, 6)
for i := 0; i < 6; i++ {
msgs = append(msgs, map[string]any{
"role": "user",
"text": "I need a kanban board",
"created_at": "2026-04-22 10:00",
})
}
tc := &StreamToolCall{
Name: "search_messages",
Input: map[string]any{"keyword": "kanban"},
Result: map[string]any{"messages": msgs},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Header, `keyword="kanban"`) || !strings.Contains(p.Header, "6 messages") {
t.Fatalf("unexpected header: %q", p.Header)
}
if !strings.Contains(p.Footer, "…and 3 more") {
t.Fatalf("expected ellipsis footer, got %q", p.Footer)
}
}
func TestFormatSpawnSuccessRatio(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "spawn",
Result: map[string]any{
"results": []any{
map[string]any{"success": true, "task": "analyze repo structure", "session_id": "sess_1"},
map[string]any{"success": true, "task": "summarize README", "session_id": "sess_2"},
},
},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Header, "2 / 2") {
t.Fatalf("unexpected header: %q", p.Header)
}
if !hasTextBlock(p.Body, "analyze repo structure") {
t.Fatalf("expected first task in body, got %+v", p.Body)
}
}
func TestFormatWebFetchShowsLink(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "web_fetch",
Input: map[string]any{"url": "https://example.com/article"},
Result: map[string]any{
"title": "Example Article Title",
"format": "markdown",
"length": float64(3421),
},
}
p := BuildToolCallEnd(tc)
link := hasLinkBlock(p.Body, "https://example.com/article")
if link == nil {
t.Fatalf("expected link block, got %+v", p.Body)
}
if link.Title != "Example Article Title" {
t.Fatalf("unexpected link title: %q", link.Title)
}
if !strings.Contains(p.Footer, "markdown") || !strings.Contains(p.Footer, "3421 chars") {
t.Fatalf("unexpected footer: %q", p.Footer)
}
}
func TestFormatCreateScheduleSuccess(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "create_schedule",
Input: map[string]any{
"name": "Daily report",
"pattern": "0 9 * * *",
},
Result: map[string]any{"id": "sch_42"},
}
p := BuildToolCallEnd(tc)
if !strings.Contains(p.Header, "[sch_42]") || !strings.Contains(p.Header, "Daily report") {
t.Fatalf("unexpected header: %q", p.Header)
}
}
func TestFormatFailureEmitsErrorFooter(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "read",
Input: map[string]any{"path": "/etc/shadow"},
Result: map[string]any{"error": "permission denied"},
}
p := BuildToolCallEnd(tc)
if p.Status != ToolCallStatusFailed {
t.Fatalf("expected failed status, got %q", p.Status)
}
if !strings.Contains(p.Footer, "permission denied") {
t.Fatalf("expected error footer, got %q", p.Footer)
}
}
func TestFormatExecFailedExitCode(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "exec",
Input: map[string]any{"command": "false"},
Result: map[string]any{
"exit_code": float64(2),
"stderr": "boom",
},
}
p := BuildToolCallEnd(tc)
if p.Status != ToolCallStatusFailed {
t.Fatalf("expected failed status, got %q", p.Status)
}
if !strings.Contains(p.Footer, "error") || !strings.Contains(p.Footer, "boom") {
t.Fatalf("expected stderr-based error footer, got %q", p.Footer)
}
}
func TestExternalToolFallsBackToGenericSummary(t *testing.T) {
t.Parallel()
tc := &StreamToolCall{
Name: "mcp.custom.do_thing",
Input: map[string]any{"foo": "bar"},
Result: map[string]any{"ok": true},
}
p := BuildToolCallEnd(tc)
if p.Emoji != ExternalToolCallEmoji {
t.Fatalf("expected external emoji, got %q", p.Emoji)
}
if p.Status != ToolCallStatusCompleted {
t.Fatalf("expected completed status, got %q", p.Status)
}
if p.InputSummary == "" {
t.Fatalf("expected generic input summary to be populated")
}
}