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`.
89 lines
3.0 KiB
Go
89 lines
3.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"testing"
|
|
|
|
sdk "github.com/memohai/twilight-ai/sdk"
|
|
)
|
|
|
|
type agentToolPlaceholderProvider struct{}
|
|
|
|
func (*agentToolPlaceholderProvider) Name() string { return "tool-placeholder-mock" }
|
|
|
|
func (*agentToolPlaceholderProvider) ListModels(context.Context) ([]sdk.Model, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (*agentToolPlaceholderProvider) Test(context.Context) *sdk.ProviderTestResult {
|
|
return &sdk.ProviderTestResult{Status: sdk.ProviderStatusOK, Message: "ok"}
|
|
}
|
|
|
|
func (*agentToolPlaceholderProvider) TestModel(context.Context, string) (*sdk.ModelTestResult, error) {
|
|
return &sdk.ModelTestResult{Supported: true, Message: "supported"}, nil
|
|
}
|
|
|
|
func (*agentToolPlaceholderProvider) DoGenerate(context.Context, sdk.GenerateParams) (*sdk.GenerateResult, error) {
|
|
return &sdk.GenerateResult{FinishReason: sdk.FinishReasonStop}, nil
|
|
}
|
|
|
|
func (*agentToolPlaceholderProvider) DoStream(_ context.Context, _ sdk.GenerateParams) (*sdk.StreamResult, error) {
|
|
ch := make(chan sdk.StreamPart, 8)
|
|
go func() {
|
|
defer close(ch)
|
|
ch <- &sdk.StartPart{}
|
|
ch <- &sdk.StartStepPart{}
|
|
ch <- &sdk.ToolInputStartPart{ID: "call-1", ToolName: "write"}
|
|
ch <- &sdk.StreamToolCallPart{
|
|
ToolCallID: "call-1",
|
|
ToolName: "write",
|
|
Input: map[string]any{"path": "/tmp/long.txt"},
|
|
}
|
|
ch <- &sdk.FinishStepPart{FinishReason: sdk.FinishReasonStop}
|
|
ch <- &sdk.FinishPart{FinishReason: sdk.FinishReasonStop}
|
|
}()
|
|
return &sdk.StreamResult{Stream: ch}, nil
|
|
}
|
|
|
|
// TestAgentStreamEmitsToolCallStartOnceWithInput asserts that each tool call
|
|
// produces exactly one EventToolCallStart with the fully-assembled Input, even
|
|
// though the underlying SDK emits a preliminary ToolInputStartPart (no input)
|
|
// followed by a StreamToolCallPart (with input). Emitting two start events per
|
|
// call caused duplicate "running" messages in IM adapters.
|
|
func TestAgentStreamEmitsToolCallStartOnceWithInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := New(Deps{})
|
|
|
|
var events []StreamEvent
|
|
for event := range a.Stream(context.Background(), RunConfig{
|
|
Model: &sdk.Model{
|
|
ID: "mock-model",
|
|
Provider: &agentToolPlaceholderProvider{},
|
|
},
|
|
Messages: []sdk.Message{sdk.UserMessage("write a long file")},
|
|
SupportsToolCall: false,
|
|
Identity: SessionContext{BotID: "bot-1"},
|
|
}) {
|
|
events = append(events, event)
|
|
}
|
|
|
|
if len(events) != 3 {
|
|
t.Fatalf("expected 3 events, got %d: %#v", len(events), events)
|
|
}
|
|
if events[0].Type != EventAgentStart {
|
|
t.Fatalf("expected first event %q, got %#v", EventAgentStart, events[0])
|
|
}
|
|
if events[1].Type != EventToolCallStart || events[1].ToolCallID != "call-1" || events[1].ToolName != "write" {
|
|
t.Fatalf("unexpected tool call start event: %#v", events[1])
|
|
}
|
|
expectedInput := map[string]any{"path": "/tmp/long.txt"}
|
|
if !reflect.DeepEqual(events[1].Input, expectedInput) {
|
|
t.Fatalf("expected tool call start input %#v, got %#v", expectedInput, events[1].Input)
|
|
}
|
|
if events[2].Type != EventAgentEnd {
|
|
t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[2])
|
|
}
|
|
}
|