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`.
214 lines
6.2 KiB
Go
214 lines
6.2 KiB
Go
package channel
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestToolCallEmojiBuiltin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := map[string]string{
|
|
"read": "📖",
|
|
"WRITE": "📝",
|
|
" edit ": "📝",
|
|
"exec": "💻",
|
|
"web_search": "🌐",
|
|
"search_memory": "🧠",
|
|
"list_schedule": "📅",
|
|
"send": "💬",
|
|
"get_contacts": "👥",
|
|
"send_email": "📧",
|
|
"browser_action": "🧭",
|
|
"spawn": "🤖",
|
|
"use_skill": "🧩",
|
|
"generate_image": "🖼️",
|
|
"speak": "🔊",
|
|
}
|
|
|
|
for name, want := range cases {
|
|
if got := ToolCallEmoji(name); got != want {
|
|
t.Fatalf("ToolCallEmoji(%q) = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToolCallEmojiExternalFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, name := range []string{"", " ", "mcp.filesystem.read", "federation_foo", "unknown_tool"} {
|
|
if got := ToolCallEmoji(name); got != ExternalToolCallEmoji {
|
|
t.Fatalf("ToolCallEmoji(%q) = %q, want external %q", name, got, ExternalToolCallEmoji)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildToolCallStartPopulatesRunning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc := &StreamToolCall{
|
|
Name: "read",
|
|
CallID: "call_1",
|
|
Input: map[string]any{"path": "/tmp/foo.txt"},
|
|
}
|
|
p := BuildToolCallStart(tc)
|
|
if p.Status != ToolCallStatusRunning {
|
|
t.Fatalf("unexpected status: %q", p.Status)
|
|
}
|
|
if p.Emoji != "📖" {
|
|
t.Fatalf("unexpected emoji: %q", p.Emoji)
|
|
}
|
|
if p.Header != "/tmp/foo.txt" {
|
|
t.Fatalf("unexpected header: %q", p.Header)
|
|
}
|
|
if p.ResultSummary != "" {
|
|
t.Fatalf("start presentation should not carry a result summary, got %q", p.ResultSummary)
|
|
}
|
|
if p.Footer != "" {
|
|
t.Fatalf("start presentation should not carry a footer, got %q", p.Footer)
|
|
}
|
|
}
|
|
|
|
func TestBuildToolCallEndInfersStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ok := &StreamToolCall{Name: "exec", Input: map[string]any{"command": "ls -la"}, Result: map[string]any{"ok": true, "exit_code": 0}}
|
|
if got := BuildToolCallEnd(ok); got.Status != ToolCallStatusCompleted {
|
|
t.Fatalf("expected completed, got %q", got.Status)
|
|
}
|
|
|
|
fail := &StreamToolCall{Name: "exec", Input: map[string]any{"command": "false"}, Result: map[string]any{"exit_code": 2, "stderr": "boom"}}
|
|
if got := BuildToolCallEnd(fail); got.Status != ToolCallStatusFailed {
|
|
t.Fatalf("expected failed, got %q", got.Status)
|
|
}
|
|
|
|
errored := &StreamToolCall{Name: "read", Input: map[string]any{"path": "/missing"}, Result: map[string]any{"error": "ENOENT"}}
|
|
if got := BuildToolCallEnd(errored); got.Status != ToolCallStatusFailed {
|
|
t.Fatalf("expected failed on error, got %q", got.Status)
|
|
}
|
|
}
|
|
|
|
func TestBuildToolCallHandlesNil(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := BuildToolCallStart(nil); !reflect.DeepEqual(got, ToolCallPresentation{}) {
|
|
t.Fatalf("expected zero-value presentation for nil start, got %+v", got)
|
|
}
|
|
if got := BuildToolCallEnd(nil); !reflect.DeepEqual(got, ToolCallPresentation{}) {
|
|
t.Fatalf("expected zero-value presentation for nil end, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderToolCallMessageLayout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
msg := RenderToolCallMessage(ToolCallPresentation{
|
|
Emoji: "📖",
|
|
ToolName: "read",
|
|
Status: ToolCallStatusRunning,
|
|
InputSummary: "/tmp/foo.txt",
|
|
ResultSummary: "",
|
|
})
|
|
if !strings.HasPrefix(msg, "📖 read · running") {
|
|
t.Fatalf("unexpected header: %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "/tmp/foo.txt") {
|
|
t.Fatalf("expected input summary in body: %q", msg)
|
|
}
|
|
|
|
done := RenderToolCallMessage(ToolCallPresentation{
|
|
Emoji: "💻",
|
|
ToolName: "exec",
|
|
Status: ToolCallStatusCompleted,
|
|
InputSummary: "ls -la",
|
|
ResultSummary: "exit=0 · stdout: total 0",
|
|
})
|
|
lines := strings.Split(done, "\n")
|
|
if len(lines) != 3 {
|
|
t.Fatalf("expected header+input+result lines, got %d: %q", len(lines), done)
|
|
}
|
|
if !strings.HasPrefix(lines[0], "💻 exec · completed") {
|
|
t.Fatalf("unexpected header: %q", lines[0])
|
|
}
|
|
}
|
|
|
|
func TestRenderToolCallMessageEmptyWhenNothingKnown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := RenderToolCallMessage(ToolCallPresentation{}); got != "" {
|
|
t.Fatalf("expected empty render, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderToolCallMessageMarkdownRendersLinks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := ToolCallPresentation{
|
|
Emoji: "🌐",
|
|
ToolName: "web_search",
|
|
Status: ToolCallStatusCompleted,
|
|
Header: `2 results for "golang generics"`,
|
|
Body: []ToolCallBlock{
|
|
{
|
|
Type: ToolCallBlockLink,
|
|
Title: "Tutorial: Getting started with generics",
|
|
URL: "https://go.dev/doc/tutorial/generics",
|
|
Desc: "A comprehensive walkthrough",
|
|
},
|
|
{
|
|
Type: ToolCallBlockLink,
|
|
Title: "Go 1.18 is released",
|
|
URL: "https://go.dev/blog/go1.18",
|
|
},
|
|
},
|
|
}
|
|
|
|
md := RenderToolCallMessageMarkdown(p)
|
|
if !strings.Contains(md, "[Tutorial: Getting started with generics](https://go.dev/doc/tutorial/generics)") {
|
|
t.Fatalf("expected markdown link for first item, got %q", md)
|
|
}
|
|
if !strings.Contains(md, "[Go 1.18 is released](https://go.dev/blog/go1.18)") {
|
|
t.Fatalf("expected markdown link for second item, got %q", md)
|
|
}
|
|
if !strings.Contains(md, "A comprehensive walkthrough") {
|
|
t.Fatalf("expected description to appear in markdown, got %q", md)
|
|
}
|
|
|
|
plain := RenderToolCallMessage(p)
|
|
if strings.Contains(plain, "](https://") {
|
|
t.Fatalf("plain render should not contain markdown link syntax, got %q", plain)
|
|
}
|
|
if !strings.Contains(plain, "Tutorial: Getting started with generics") || !strings.Contains(plain, "https://go.dev/doc/tutorial/generics") {
|
|
t.Fatalf("plain render should carry title and url lines, got %q", plain)
|
|
}
|
|
}
|
|
|
|
func TestRenderToolCallMessageMarkdownCodeBlocks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := ToolCallPresentation{
|
|
Emoji: "💻",
|
|
ToolName: "exec",
|
|
Status: ToolCallStatusCompleted,
|
|
Header: "$ ls -la",
|
|
Body: []ToolCallBlock{
|
|
{Type: ToolCallBlockCode, Text: "total 0\ndrwxr-xr-x 2 user"},
|
|
},
|
|
Footer: "exit=0",
|
|
}
|
|
|
|
md := RenderToolCallMessageMarkdown(p)
|
|
if !strings.Contains(md, "```\ntotal 0\ndrwxr-xr-x 2 user\n```") {
|
|
t.Fatalf("expected fenced code block, got %q", md)
|
|
}
|
|
|
|
plain := RenderToolCallMessage(p)
|
|
if strings.Contains(plain, "```") {
|
|
t.Fatalf("plain render should not fence code, got %q", plain)
|
|
}
|
|
if !strings.Contains(plain, "total 0") {
|
|
t.Fatalf("plain render should still include code body, got %q", plain)
|
|
}
|
|
}
|