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`.
148 lines
3.7 KiB
Go
148 lines
3.7 KiB
Go
package channel
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSummarizeToolInputFileField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolInput("read", map[string]any{"path": "/var/log/syslog"})
|
|
if got != "/var/log/syslog" {
|
|
t.Fatalf("unexpected: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolInputCommandFirstLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolInput("exec", map[string]any{"command": "echo hi\nsleep 10"})
|
|
if got != "echo hi" {
|
|
t.Fatalf("unexpected first line: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolInputMessageTargetAndBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolInput("send", map[string]any{
|
|
"target": "chat:123",
|
|
"body": "Hello there",
|
|
})
|
|
if !strings.Contains(got, "chat:123") || !strings.Contains(got, "Hello there") {
|
|
t.Fatalf("unexpected target/body summary: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolInputScheduleID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolInput("update_schedule", map[string]any{
|
|
"id": "sch_42",
|
|
"cron": "0 9 * * *",
|
|
})
|
|
if got != "sch_42 · 0 9 * * *" {
|
|
t.Fatalf("unexpected schedule summary: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolInputTruncatesLongValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
long := strings.Repeat("x", 400)
|
|
got := SummarizeToolInput("web_fetch", map[string]any{"url": long})
|
|
if !strings.HasSuffix(got, "…") {
|
|
t.Fatalf("expected truncation suffix, got %q", got)
|
|
}
|
|
if len([]rune(got)) > 201 {
|
|
t.Fatalf("summary not truncated: rune len=%d", len([]rune(got)))
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolInputFallbackCompactJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolInput("unknown", map[string]any{"alpha": 1, "beta": 2})
|
|
if !strings.Contains(got, "\"alpha\"") || !strings.Contains(got, "\"beta\"") {
|
|
t.Fatalf("expected JSON fallback: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolResultPrefersError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolResult("read", map[string]any{"error": "ENOENT", "ok": false})
|
|
if !strings.HasPrefix(got, "error: ENOENT") {
|
|
t.Fatalf("unexpected result: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolResultCombinesSignals(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolResult("exec", map[string]any{
|
|
"ok": true,
|
|
"exit_code": 0,
|
|
"stdout": "line1\nline2",
|
|
})
|
|
if !strings.Contains(got, "ok=true") {
|
|
t.Fatalf("missing ok signal: %q", got)
|
|
}
|
|
if !strings.Contains(got, "exit=0") {
|
|
t.Fatalf("missing exit_code: %q", got)
|
|
}
|
|
if !strings.Contains(got, "stdout: line1") {
|
|
t.Fatalf("missing stdout first line: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolResultPlainString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := SummarizeToolResult("read", "hello world")
|
|
if got != "hello world" {
|
|
t.Fatalf("unexpected plain result: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeToolResultLargeJSONFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
items := make([]any, 0, 10)
|
|
for i := 0; i < 10; i++ {
|
|
items = append(items, map[string]any{"id": i})
|
|
}
|
|
got := SummarizeToolResult("list", map[string]any{"items": items})
|
|
if got == "" {
|
|
t.Fatalf("expected non-empty summary")
|
|
}
|
|
if len([]rune(got)) > 201 {
|
|
t.Fatalf("expected truncated: %d", len([]rune(got)))
|
|
}
|
|
}
|
|
|
|
func TestIsToolResultFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
result any
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"ok_true", map[string]any{"ok": true}, false},
|
|
{"ok_false", map[string]any{"ok": false}, true},
|
|
{"error_present", map[string]any{"error": "bad"}, true},
|
|
{"empty_error", map[string]any{"error": ""}, false},
|
|
{"exit_zero", map[string]any{"exit_code": 0}, false},
|
|
{"exit_nonzero", map[string]any{"exit_code": 2}, true},
|
|
{"plain_string", "hello", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := isToolResultFailure(tc.result); got != tc.want {
|
|
t.Fatalf("%s: isToolResultFailure = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|