Files
Memoh/internal/channel/toolcall_summary_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

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)
}
}
}