mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
473d559042
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`.
359 lines
9.7 KiB
Go
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")
|
|
}
|
|
}
|