mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-13 02:22:33 +00:00
Quick Ack and Intermediate Replies now require generated delivery text in generated modes, while fixed-template mode remains explicit. Includes provider/model resolution coverage and UI copy updates.
449 lines
16 KiB
Go
449 lines
16 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
)
|
|
|
|
func TestResolveChatBehavior_InheritsGlobalAndChannelOverride(t *testing.T) {
|
|
fixedMode := QuickAckModeFixedTemplate
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{
|
|
Enabled: new(true),
|
|
Mode: &fixedMode,
|
|
MinDelayMs: new(750),
|
|
Templates: []string{"On it."},
|
|
},
|
|
FinalSplit: &config.FinalSplitConfig{
|
|
Enabled: new(true),
|
|
MinChars: new(1200),
|
|
MaxMessages: new(3),
|
|
DelayMs: new(400),
|
|
},
|
|
}
|
|
override := &config.ChatBehaviorConfig{
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(false)},
|
|
FinalSplit: &config.FinalSplitConfig{
|
|
MaxMessages: new(2),
|
|
},
|
|
}
|
|
|
|
got := ResolveChatBehavior(global, override)
|
|
|
|
if !got.Enabled {
|
|
t.Fatal("Enabled = false, want true")
|
|
}
|
|
if got.QuickAck.Enabled {
|
|
t.Fatal("QuickAck.Enabled = true, want channel override false")
|
|
}
|
|
if got.QuickAck.MinDelayMs != 750 {
|
|
t.Fatalf("QuickAck.MinDelayMs = %d, want 750", got.QuickAck.MinDelayMs)
|
|
}
|
|
if got.FinalSplit.MaxMessages != 2 {
|
|
t.Fatalf("FinalSplit.MaxMessages = %d, want override 2", got.FinalSplit.MaxMessages)
|
|
}
|
|
if got.FinalSplit.MinChars != 1200 || got.FinalSplit.DelayMs != 400 {
|
|
t.Fatalf("FinalSplit inherited fields = %+v, want min=1200 delay=400", got.FinalSplit)
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehavior_DefaultQuickAckModeIsLLMGenerated(t *testing.T) {
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"Fallback."}},
|
|
}
|
|
|
|
got := ResolveChatBehavior(global, nil)
|
|
|
|
if got.QuickAck.Mode != QuickAckModeLLMGenerated {
|
|
t.Fatalf("QuickAck.Mode = %q, want %q", got.QuickAck.Mode, QuickAckModeLLMGenerated)
|
|
}
|
|
if ShouldDeliverGeneratedProgress(got, false) {
|
|
t.Fatal("generated progress coupled to quick ack; want independent intermediate_replies gate")
|
|
}
|
|
if !ShouldSendQuickAck(got, false) {
|
|
t.Fatal("fallback quick ack disabled for non-streaming llm_generated mode")
|
|
}
|
|
if got.QuickAck.Templates[0] != "Fallback." {
|
|
t.Fatalf("fallback template = %q, want configured fallback", got.QuickAck.Templates[0])
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehavior_IntermediateRepliesIndependentFromQuickAck(t *testing.T) {
|
|
mode := IntermediateModeSidecar
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{
|
|
Enabled: new(true),
|
|
Mode: &mode,
|
|
},
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(false), Templates: []string{"Fallback."}},
|
|
}
|
|
|
|
got := ResolveChatBehavior(global, nil)
|
|
|
|
if !ShouldDeliverGeneratedProgress(got, false) {
|
|
t.Fatal("intermediate replies disabled when quick ack is off")
|
|
}
|
|
if ShouldSendQuickAck(got, false) {
|
|
t.Fatal("quick ack enabled despite explicit false")
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehaviorWithAgent_ChannelBeatsAgentBeatsWorkspace(t *testing.T) {
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{
|
|
Enabled: new(false),
|
|
Provider: "workspace-provider",
|
|
},
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(false), Provider: "workspace-provider"},
|
|
}
|
|
agentOverride := &config.ChatBehaviorConfig{
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{
|
|
Enabled: new(true),
|
|
Provider: "agent-provider",
|
|
},
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Provider: "agent-provider"},
|
|
}
|
|
channelOverride := &config.ChatBehaviorConfig{
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(false), Provider: "channel-provider"},
|
|
}
|
|
|
|
got := ResolveChatBehaviorWithAgent(global, agentOverride, channelOverride)
|
|
|
|
if !got.IntermediateReplies.Enabled || got.IntermediateReplies.Provider != "agent-provider" {
|
|
t.Fatalf("intermediate = %+v, want agent override", got.IntermediateReplies)
|
|
}
|
|
if got.QuickAck.Enabled || got.QuickAck.Provider != "channel-provider" {
|
|
t.Fatalf("quick ack = %+v, want channel override", got.QuickAck)
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehaviorWithAgent_FieldLevelPrecedence(t *testing.T) {
|
|
quickMode := QuickAckModeSidecar
|
|
fixedMode := QuickAckModeFixedTemplate
|
|
intermediateMode := IntermediateModeSidecar
|
|
offMode := IntermediateModeOff
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{
|
|
Enabled: new(false),
|
|
Mode: &offMode,
|
|
Provider: "global-intermediate-provider",
|
|
Model: "global-intermediate-model",
|
|
TimeoutMs: new(1000),
|
|
MaxTokens: new(10),
|
|
MaxChars: new(100),
|
|
},
|
|
QuickAck: &config.QuickAckConfig{
|
|
Enabled: new(false),
|
|
Mode: &fixedMode,
|
|
MinDelayMs: new(100),
|
|
Provider: "global-ack-provider",
|
|
Model: "global-ack-model",
|
|
TimeoutMs: new(1100),
|
|
MaxTokens: new(11),
|
|
MaxChars: new(111),
|
|
Templates: []string{"global template"},
|
|
},
|
|
}
|
|
agentOverride := &config.ChatBehaviorConfig{
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{
|
|
Enabled: new(true),
|
|
Mode: &intermediateMode,
|
|
Provider: "agent-intermediate-provider",
|
|
Model: "agent-intermediate-model",
|
|
TimeoutMs: new(2000),
|
|
MaxTokens: new(20),
|
|
MaxChars: new(200),
|
|
},
|
|
QuickAck: &config.QuickAckConfig{
|
|
Enabled: new(true),
|
|
Mode: &quickMode,
|
|
MinDelayMs: new(200),
|
|
Provider: "agent-ack-provider",
|
|
Model: "agent-ack-model",
|
|
TimeoutMs: new(2200),
|
|
MaxTokens: new(22),
|
|
MaxChars: new(222),
|
|
Templates: []string{"agent template"},
|
|
},
|
|
}
|
|
channelOverride := &config.ChatBehaviorConfig{
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{
|
|
Provider: "channel-intermediate-provider",
|
|
TimeoutMs: new(3000),
|
|
},
|
|
QuickAck: &config.QuickAckConfig{
|
|
Enabled: new(false),
|
|
Model: "channel-ack-model",
|
|
MaxTokens: new(33),
|
|
MinDelayMs: new(300),
|
|
},
|
|
}
|
|
|
|
got := ResolveChatBehaviorWithAgent(global, agentOverride, channelOverride)
|
|
|
|
if !got.Enabled {
|
|
t.Fatal("Enabled = false, want global enabled")
|
|
}
|
|
if !got.IntermediateReplies.Enabled {
|
|
t.Fatal("Intermediate enabled = false, want agent override true")
|
|
}
|
|
if got.IntermediateReplies.Mode != IntermediateModeSidecar {
|
|
t.Fatalf("Intermediate mode = %q, want agent sidecar", got.IntermediateReplies.Mode)
|
|
}
|
|
if got.IntermediateReplies.Provider != "channel-intermediate-provider" || got.IntermediateReplies.Model != "agent-intermediate-model" {
|
|
t.Fatalf("Intermediate provider/model = %q/%q, want channel provider + agent model", got.IntermediateReplies.Provider, got.IntermediateReplies.Model)
|
|
}
|
|
if got.IntermediateReplies.Timeout != 3*time.Second || got.IntermediateReplies.MaxTokens != 20 || got.IntermediateReplies.MaxChars != 200 {
|
|
t.Fatalf("Intermediate limits = %+v, want channel timeout + agent token/char limits", got.IntermediateReplies)
|
|
}
|
|
if got.QuickAck.Enabled {
|
|
t.Fatal("QuickAck enabled = true, want channel override false")
|
|
}
|
|
if got.QuickAck.Mode != QuickAckModeSidecar {
|
|
t.Fatalf("QuickAck mode = %q, want agent sidecar", got.QuickAck.Mode)
|
|
}
|
|
if got.QuickAck.Provider != "agent-ack-provider" || got.QuickAck.Model != "channel-ack-model" {
|
|
t.Fatalf("QuickAck provider/model = %q/%q, want agent provider + channel model", got.QuickAck.Provider, got.QuickAck.Model)
|
|
}
|
|
if got.QuickAck.MinDelayMs != 300 || got.QuickAck.Timeout != 2200*time.Millisecond || got.QuickAck.MaxTokens != 33 || got.QuickAck.MaxChars != 222 {
|
|
t.Fatalf("QuickAck limits = %+v, want merged channel/agent limits", got.QuickAck)
|
|
}
|
|
if !reflect.DeepEqual(got.QuickAck.Templates, []string{"agent template"}) {
|
|
t.Fatalf("QuickAck templates = %#v, want agent template", got.QuickAck.Templates)
|
|
}
|
|
}
|
|
|
|
func TestChatBehaviorConfigWithIntermediateDefault_UsesLegacyBlockReplyOnlyWhenUnset(t *testing.T) {
|
|
legacyEnabled := true
|
|
explicitDisabled := false
|
|
base := &config.ChatBehaviorConfig{
|
|
IntermediateReplies: &config.IntermediateRepliesConfig{Enabled: &explicitDisabled},
|
|
}
|
|
|
|
got := ChatBehaviorConfigWithIntermediateDefault(base, &legacyEnabled)
|
|
if got.IntermediateReplies == nil || got.IntermediateReplies.Enabled == nil || *got.IntermediateReplies.Enabled {
|
|
t.Fatalf("intermediate enabled = %#v, want explicit false to win", got.IntermediateReplies)
|
|
}
|
|
if base.IntermediateReplies.Enabled == nil || *base.IntermediateReplies.Enabled {
|
|
t.Fatalf("mutated source config = %#v", base.IntermediateReplies)
|
|
}
|
|
|
|
got = ChatBehaviorConfigWithIntermediateDefault(nil, &legacyEnabled)
|
|
if got == nil || got.IntermediateReplies == nil || got.IntermediateReplies.Enabled == nil || !*got.IntermediateReplies.Enabled {
|
|
t.Fatalf("legacy default not applied: %#v", got)
|
|
}
|
|
if got.Enabled == nil || !*got.Enabled {
|
|
t.Fatalf("legacy block_reply=true did not enable chat behavior: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehaviorWithAgent_ChannelBlockReplySeedsIntermediateDefault(t *testing.T) {
|
|
global := &config.ChatBehaviorConfig{Enabled: new(true)}
|
|
globalBlockReply := true
|
|
channelBlockReply := false
|
|
mgr := NewManager(bus.New())
|
|
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test", blockReply: &channelBlockReply})
|
|
|
|
got := mgr.ResolveChatBehaviorWithAgent("test", ChatBehaviorConfigWithIntermediateDefault(global, &globalBlockReply), nil)
|
|
|
|
if got.IntermediateReplies.Enabled {
|
|
t.Fatalf("intermediate enabled = true, want legacy channel block_reply=false to win")
|
|
}
|
|
}
|
|
|
|
func TestParseAgentDeliveryBehaviorConfig(t *testing.T) {
|
|
raw := []byte(`{"unrelated":true,"delivery_behavior":{"enabled":true,"quick_ack":{"enabled":true,"provider":"groq"},"intermediate_replies":{"enabled":false}}}`)
|
|
|
|
got := ParseAgentDeliveryBehaviorConfig(raw)
|
|
|
|
if got == nil || got.QuickAck == nil || got.QuickAck.Provider != "groq" {
|
|
t.Fatalf("agent delivery behavior = %#v, want quick ack provider", got)
|
|
}
|
|
if got.IntermediateReplies == nil || got.IntermediateReplies.Enabled == nil || *got.IntermediateReplies.Enabled {
|
|
t.Fatalf("intermediate override = %#v, want enabled=false", got.IntermediateReplies)
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehavior_QuickAckModeOffKeepsFallbackDisabled(t *testing.T) {
|
|
mode := QuickAckModeOff
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &mode, Templates: []string{"Fallback."}},
|
|
}
|
|
|
|
got := ResolveChatBehavior(global, nil)
|
|
|
|
if got.QuickAck.Mode != QuickAckModeOff {
|
|
t.Fatalf("QuickAck.Mode = %q, want off", got.QuickAck.Mode)
|
|
}
|
|
if ShouldDeliverGeneratedProgress(got, false) {
|
|
t.Fatal("generated progress enabled in off mode")
|
|
}
|
|
if ShouldSendQuickAck(got, false) {
|
|
t.Fatal("fallback quick ack enabled in off mode")
|
|
}
|
|
}
|
|
|
|
func TestResolveChatBehavior_ExplicitFixedTemplateMode(t *testing.T) {
|
|
mode := QuickAckModeFixedTemplate
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &mode, Templates: []string{"Working."}},
|
|
}
|
|
|
|
got := ResolveChatBehavior(global, nil)
|
|
|
|
if got.QuickAck.Mode != QuickAckModeFixedTemplate {
|
|
t.Fatalf("QuickAck.Mode = %q, want fixed_template", got.QuickAck.Mode)
|
|
}
|
|
if ShouldDeliverGeneratedProgress(got, false) {
|
|
t.Fatal("generated progress enabled in fixed_template mode")
|
|
}
|
|
if !ShouldSendQuickAck(got, false) {
|
|
t.Fatal("fixed template quick ack disabled")
|
|
}
|
|
}
|
|
|
|
func TestSplitFinalMessages_ConservativeParagraphSplit(t *testing.T) {
|
|
cfg := ResolvedFinalSplitConfig{Enabled: true, MinChars: 20, MaxMessages: 3}
|
|
text := "First part is useful.\n\nSecond part is also useful.\n\nThird part closes it."
|
|
|
|
got := SplitFinalMessages(text, cfg)
|
|
want := []string{"First part is useful.", "Second part is also useful.", "Third part closes it."}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("SplitFinalMessages() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSplitFinalMessages_DoesNotSplitUnsafeMarkdown(t *testing.T) {
|
|
cfg := ResolvedFinalSplitConfig{Enabled: true, MinChars: 10, MaxMessages: 3}
|
|
cases := map[string]string{
|
|
"fenced code": "Intro.\n\n```go\nfmt.Println(\"hi\")\n```\n\nDone.",
|
|
"table": "A | B\n--- | ---\n1 | 2\n\nDone.",
|
|
"list": "Intro.\n\n- one\n- two\n\nDone.",
|
|
"quote": "Intro.\n\n> quoted\n> text\n\nDone.",
|
|
"json": "Intro.\n\n{\"ok\": true}\n\nDone.",
|
|
"url paragraph": "Intro.\n\nhttps://example.com/a/b?c=d\n\nDone.",
|
|
}
|
|
|
|
for name, text := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
got := SplitFinalMessages(text, cfg)
|
|
if len(got) != 1 || got[0] != text {
|
|
t.Fatalf("SplitFinalMessages() = %#v, want original single message", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreviewChatBehavior_NoSideEffects(t *testing.T) {
|
|
fixedMode := QuickAckModeFixedTemplate
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &fixedMode, Templates: []string{"Working."}},
|
|
FinalSplit: &config.FinalSplitConfig{Enabled: new(true), MinChars: new(10), MaxMessages: new(2)},
|
|
}
|
|
|
|
got := PreviewChatBehavior(global, nil, ChatBehaviorPreviewOptions{
|
|
Content: "Part one is long.\n\nPart two is long.",
|
|
IsStreaming: false,
|
|
HasToolCalls: true,
|
|
})
|
|
|
|
if !got.Ack.ShouldSend || got.Ack.Content != "Working." {
|
|
t.Fatalf("Ack preview = %+v, want send Working.", got.Ack)
|
|
}
|
|
if got.Ack.Mode != QuickAckModeFixedTemplate || got.Ack.Source != QuickAckSourceTemplate {
|
|
t.Fatalf("Ack preview mode/source = %q/%q, want fixed_template/template", got.Ack.Mode, got.Ack.Source)
|
|
}
|
|
if len(got.Split.Parts) != 2 {
|
|
t.Fatalf("Split parts = %#v, want two parts", got.Split.Parts)
|
|
}
|
|
}
|
|
|
|
func TestPreviewChatBehavior_GeneratedModeReportsLLMOnly(t *testing.T) {
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"Fallback."}},
|
|
}
|
|
|
|
got := PreviewChatBehavior(global, nil, ChatBehaviorPreviewOptions{
|
|
Content: "Part one.\n\nPart two.",
|
|
IsStreaming: false,
|
|
HasToolCalls: true,
|
|
})
|
|
|
|
if !got.Ack.ShouldSend || got.Ack.Mode != QuickAckModeLLMGenerated || got.Ack.Source != QuickAckSourceGenerated {
|
|
t.Fatalf("Ack preview = %+v, want generated-first send decision", got.Ack)
|
|
}
|
|
if got.Ack.Content != "" {
|
|
t.Fatalf("Ack preview content = %q, want empty value for generated mode", got.Ack.Content)
|
|
}
|
|
}
|
|
|
|
func TestPreviewChatBehavior_GeneratedModeWithoutToolCallsStillReportsLLMOnly(t *testing.T) {
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"Fallback."}},
|
|
}
|
|
|
|
got := PreviewChatBehavior(global, nil, ChatBehaviorPreviewOptions{
|
|
Content: "Short final answer.",
|
|
IsStreaming: false,
|
|
HasToolCalls: false,
|
|
})
|
|
|
|
if !got.Ack.ShouldSend || got.Ack.Mode != QuickAckModeLLMGenerated || got.Ack.Source != QuickAckSourceGenerated {
|
|
t.Fatalf("Ack preview = %+v, want generated send decision", got.Ack)
|
|
}
|
|
if got.Ack.Content != "" {
|
|
t.Fatalf("Ack preview content = %q, want empty value for generated mode", got.Ack.Content)
|
|
}
|
|
}
|
|
|
|
func TestManagerResolveChatBehavior_UsesChannelOverride(t *testing.T) {
|
|
fixedMode := QuickAckModeFixedTemplate
|
|
global := &config.ChatBehaviorConfig{
|
|
Enabled: new(true),
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &fixedMode, Templates: []string{"global"}},
|
|
}
|
|
override := &config.ChatBehaviorConfig{
|
|
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &fixedMode, Templates: []string{"channel"}},
|
|
}
|
|
mgr := NewManager(bus.New())
|
|
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test", behavior: override})
|
|
|
|
got := mgr.ResolveChatBehavior("test", global)
|
|
|
|
if got.QuickAck.Templates[0] != "channel" {
|
|
t.Fatalf("QuickAck template = %q, want channel override", got.QuickAck.Templates[0])
|
|
}
|
|
}
|
|
|
|
type chatBehaviorTestChannel struct {
|
|
name string
|
|
behavior *config.ChatBehaviorConfig
|
|
blockReply *bool
|
|
}
|
|
|
|
func (c *chatBehaviorTestChannel) Name() string { return c.name }
|
|
func (c *chatBehaviorTestChannel) Type() string { return c.name }
|
|
func (c *chatBehaviorTestChannel) Start(context.Context) error { return nil }
|
|
func (c *chatBehaviorTestChannel) Stop(context.Context) error { return nil }
|
|
func (c *chatBehaviorTestChannel) Send(context.Context, bus.OutboundMessage) error { return nil }
|
|
func (c *chatBehaviorTestChannel) IsRunning() bool { return true }
|
|
func (c *chatBehaviorTestChannel) IsAllowed(string) bool { return true }
|
|
func (c *chatBehaviorTestChannel) ChatBehaviorConfig() *config.ChatBehaviorConfig { return c.behavior }
|
|
func (c *chatBehaviorTestChannel) BlockReplyEnabled() *bool { return c.blockReply }
|