Files
Memoh/internal/agent/stream_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

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