mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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`.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user