mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
feat(channels): use generated quick ack progress
This commit is contained in:
1 parent
afa32f5fa0
commit
32273f04d3
20 files changed
+431
-68
No files matched your search
@@ -539,10 +539,11 @@ func processNormalMessage(
|
||||
return
|
||||
}
|
||||
|
||||
interimReplyEnabled := blockReplyEnabled || channels.ShouldDeliverGeneratedProgress(chatBehavior, streaming)
|
||||
// Dedup: if block replies were delivered and the final content matches the last
|
||||
// block reply, suppress the final message to avoid duplicate delivery.
|
||||
// Only applies when blockReply is enabled (otherwise nothing was delivered).
|
||||
if blockReplyEnabled && outcome.Result.BlockReplies > 0 && outcome.Result.Content == outcome.Result.LastBlockReply && len(outcome.Result.Media) == 0 {
|
||||
// Only applies when an interim reply mode is enabled (otherwise nothing was delivered).
|
||||
if interimReplyEnabled && outcome.Result.BlockReplies > 0 && outcome.Result.Content == outcome.Result.LastBlockReply && len(outcome.Result.Media) == 0 {
|
||||
slog.Debug("inbound: dedup final message (matches last block reply)",
|
||||
"channel", channel, "run_id", rID)
|
||||
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
|
||||
|
||||
@@ -75,12 +75,17 @@ Normal channel messages pass through the shared inbound debouncer before agent e
|
||||
|
||||
`gateway.chat_behavior` controls optional channel-only delivery polish:
|
||||
|
||||
- `quick_ack` sends one short acknowledgement after a configurable delay, only for non-streaming channel runs.
|
||||
- `quick_ack.mode = "llm_generated"` uses the existing main-turn `block.reply` event as the natural progress response for non-streaming channel runs. It does not make a separate LLM call.
|
||||
- `quick_ack.templates` are fallback messages. In generated mode, the first template is sent only if no generated `block.reply` arrives before `quick_ack.min_delay_ms`.
|
||||
- `quick_ack.mode = "fixed_template"` preserves the older fixed-template acknowledgement behavior.
|
||||
- `quick_ack.mode = "off"` disables chat-behavior quick acknowledgement without disabling explicit `gateway.block_reply`.
|
||||
- `final_split` splits long final text replies into a bounded number of paragraph messages.
|
||||
- Per-channel `chat_behavior` overrides inherit the gateway config unless a field is explicitly set.
|
||||
|
||||
Splitting is intentionally conservative. Replies containing fenced code, tables, lists, quotes, JSON/XML-ish blocks, or URL-only paragraphs remain a single message. Media replies and streaming deliveries are not split.
|
||||
|
||||
Progress messages are not added to session history by this behavior. Existing run timeline handling for `block.reply` remains unchanged.
|
||||
|
||||
**Multi-attachment coalescing (#63).** Messages carrying attachments do NOT bypass the debouncer — that pre-fix shortcut was the source of N-replies for one user action. Instead, when media is present the effective window is `max(configured, mediaFloor)` so multi-file uploads land in the same buffer and flush together. Three surfaces apply the same invariant:
|
||||
|
||||
| Surface | Buffer key | Trigger |
|
||||
|
||||
@@ -12,6 +12,14 @@ const (
|
||||
defaultFinalSplitMax = 3
|
||||
defaultFinalSplitDelay = 500
|
||||
defaultAckTemplate = "Got it. Working on it..."
|
||||
|
||||
QuickAckModeLLMGenerated = "llm_generated"
|
||||
QuickAckModeFixedTemplate = "fixed_template"
|
||||
QuickAckModeOff = "off"
|
||||
|
||||
QuickAckSourceGenerated = "generated"
|
||||
QuickAckSourceTemplate = "template"
|
||||
QuickAckSourceOff = "off"
|
||||
)
|
||||
|
||||
type ResolvedChatBehavior struct {
|
||||
@@ -22,6 +30,7 @@ type ResolvedChatBehavior struct {
|
||||
|
||||
type ResolvedQuickAckConfig struct {
|
||||
Enabled bool
|
||||
Mode string
|
||||
MinDelayMs int
|
||||
Templates []string
|
||||
}
|
||||
@@ -46,8 +55,11 @@ type ChatBehaviorPreview struct {
|
||||
}
|
||||
|
||||
type AckPreview struct {
|
||||
ShouldSend bool `json:"shouldSend"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ShouldSend bool `json:"shouldSend"`
|
||||
Content string `json:"content,omitempty"`
|
||||
FallbackContent string `json:"fallbackContent,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type SplitPreview struct {
|
||||
@@ -57,6 +69,7 @@ type SplitPreview struct {
|
||||
func ResolveChatBehavior(global, override *config.ChatBehaviorConfig) ResolvedChatBehavior {
|
||||
resolved := ResolvedChatBehavior{
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Mode: QuickAckModeLLMGenerated,
|
||||
MinDelayMs: defaultQuickAckDelayMs,
|
||||
Templates: []string{defaultAckTemplate},
|
||||
},
|
||||
@@ -89,6 +102,9 @@ func applyChatBehavior(dst *ResolvedChatBehavior, src *config.ChatBehaviorConfig
|
||||
if src.QuickAck.Enabled != nil {
|
||||
dst.QuickAck.Enabled = *src.QuickAck.Enabled
|
||||
}
|
||||
if src.QuickAck.Mode != nil {
|
||||
dst.QuickAck.Mode = normalizeQuickAckMode(*src.QuickAck.Mode)
|
||||
}
|
||||
if src.QuickAck.MinDelayMs != nil {
|
||||
dst.QuickAck.MinDelayMs = max(0, *src.QuickAck.MinDelayMs)
|
||||
}
|
||||
@@ -127,18 +143,84 @@ func cleanTemplates(values []string) []string {
|
||||
|
||||
func PreviewChatBehavior(global, override *config.ChatBehaviorConfig, opts ChatBehaviorPreviewOptions) ChatBehaviorPreview {
|
||||
resolved := ResolveChatBehavior(global, override)
|
||||
return PreviewResolvedChatBehavior(resolved, opts)
|
||||
}
|
||||
|
||||
func PreviewResolvedChatBehavior(resolved ResolvedChatBehavior, opts ChatBehaviorPreviewOptions) ChatBehaviorPreview {
|
||||
preview := ChatBehaviorPreview{
|
||||
Resolved: resolved,
|
||||
Split: SplitPreview{Parts: SplitFinalMessages(opts.Content, resolved.FinalSplit)},
|
||||
}
|
||||
if ShouldSendQuickAck(resolved, opts.IsStreaming) {
|
||||
preview.Ack = AckPreview{ShouldSend: true, Content: resolved.QuickAck.Templates[0]}
|
||||
}
|
||||
preview.Ack = buildAckPreview(resolved, opts.IsStreaming, opts.HasToolCalls)
|
||||
return preview
|
||||
}
|
||||
|
||||
func ShouldSendQuickAck(behavior ResolvedChatBehavior, streaming bool) bool {
|
||||
return behavior.Enabled && behavior.QuickAck.Enabled && !streaming
|
||||
if !behavior.Enabled || !behavior.QuickAck.Enabled || streaming {
|
||||
return false
|
||||
}
|
||||
switch effectiveQuickAckMode(behavior.QuickAck.Mode) {
|
||||
case QuickAckModeLLMGenerated, QuickAckModeFixedTemplate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldDeliverGeneratedProgress(behavior ResolvedChatBehavior, streaming bool) bool {
|
||||
return behavior.Enabled &&
|
||||
behavior.QuickAck.Enabled &&
|
||||
!streaming &&
|
||||
effectiveQuickAckMode(behavior.QuickAck.Mode) == QuickAckModeLLMGenerated
|
||||
}
|
||||
|
||||
func normalizeQuickAckMode(mode string) string {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case QuickAckModeFixedTemplate:
|
||||
return QuickAckModeFixedTemplate
|
||||
case QuickAckModeOff:
|
||||
return QuickAckModeOff
|
||||
default:
|
||||
return QuickAckModeLLMGenerated
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveQuickAckMode(mode string) string {
|
||||
if strings.TrimSpace(mode) == "" {
|
||||
return QuickAckModeLLMGenerated
|
||||
}
|
||||
return normalizeQuickAckMode(mode)
|
||||
}
|
||||
|
||||
func buildAckPreview(behavior ResolvedChatBehavior, streaming, hasToolCalls bool) AckPreview {
|
||||
mode := effectiveQuickAckMode(behavior.QuickAck.Mode)
|
||||
preview := AckPreview{Mode: mode}
|
||||
if !behavior.Enabled || !behavior.QuickAck.Enabled || streaming {
|
||||
preview.Source = QuickAckSourceOff
|
||||
return preview
|
||||
}
|
||||
if mode == QuickAckModeOff {
|
||||
preview.Source = QuickAckSourceOff
|
||||
return preview
|
||||
}
|
||||
if len(behavior.QuickAck.Templates) == 0 {
|
||||
return preview
|
||||
}
|
||||
if mode == QuickAckModeFixedTemplate {
|
||||
preview.ShouldSend = true
|
||||
preview.Source = QuickAckSourceTemplate
|
||||
preview.Content = behavior.QuickAck.Templates[0]
|
||||
return preview
|
||||
}
|
||||
preview.ShouldSend = true
|
||||
if hasToolCalls {
|
||||
preview.Source = QuickAckSourceGenerated
|
||||
preview.FallbackContent = behavior.QuickAck.Templates[0]
|
||||
return preview
|
||||
}
|
||||
preview.Source = QuickAckSourceTemplate
|
||||
preview.Content = behavior.QuickAck.Templates[0]
|
||||
return preview
|
||||
}
|
||||
|
||||
func SplitFinalMessages(content string, cfg ResolvedFinalSplitConfig) []string {
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestHandleAgentEvent_QuickAckNonStreamingOnly(t *testing.T) {
|
||||
Enabled: true,
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Enabled: true,
|
||||
Mode: QuickAckModeFixedTemplate,
|
||||
MinDelayMs: 0,
|
||||
Templates: []string{"On it."},
|
||||
},
|
||||
@@ -60,6 +61,7 @@ func TestUnregisterRun_CancelsPendingQuickAck(t *testing.T) {
|
||||
Enabled: true,
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Enabled: true,
|
||||
Mode: QuickAckModeFixedTemplate,
|
||||
MinDelayMs: 50,
|
||||
Templates: []string{"On it."},
|
||||
},
|
||||
@@ -85,6 +87,7 @@ func TestCancelQuickAck_BlocksInFlightSend(t *testing.T) {
|
||||
Enabled: true,
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Enabled: true,
|
||||
Mode: QuickAckModeFixedTemplate,
|
||||
Templates: []string{"On it."},
|
||||
},
|
||||
},
|
||||
@@ -99,3 +102,97 @@ func TestCancelQuickAck_BlocksInFlightSend(t *testing.T) {
|
||||
t.Fatalf("cancelled quick ack emitted message: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentEvent_GeneratedProgressCancelsFallback(t *testing.T) {
|
||||
behavior := ResolvedChatBehavior{
|
||||
Enabled: true,
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Enabled: true,
|
||||
Mode: QuickAckModeLLMGenerated,
|
||||
MinDelayMs: 50,
|
||||
Templates: []string{"Fallback."},
|
||||
},
|
||||
}
|
||||
|
||||
mb := bus.New()
|
||||
mgr := NewManager(mb)
|
||||
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
|
||||
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", map[string]string{"local_key": "chat-1/topic"}, uuid.Nil, false, false, true, behavior)
|
||||
|
||||
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
|
||||
mgr.HandleAgentEvent(protocol.AgentEventBlockReply, "run-1", map[string]string{"content": "I will check that now."})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
got, ok := mb.SubscribeOutbound(ctx)
|
||||
if !ok {
|
||||
t.Fatal("expected generated progress outbound message")
|
||||
}
|
||||
if got.Content != "I will check that now." || got.Metadata["local_key"] != "chat-1/topic" {
|
||||
t.Fatalf("generated progress outbound = %+v, want generated content and routing metadata", got)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 75*time.Millisecond)
|
||||
defer cancel()
|
||||
if got, ok := mb.SubscribeOutbound(ctx); ok {
|
||||
t.Fatalf("fallback emitted after generated progress: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentEvent_GeneratedModeFallsBackWithoutBlockReply(t *testing.T) {
|
||||
behavior := ResolvedChatBehavior{
|
||||
Enabled: true,
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Enabled: true,
|
||||
Mode: QuickAckModeLLMGenerated,
|
||||
MinDelayMs: 0,
|
||||
Templates: []string{"Fallback."},
|
||||
},
|
||||
}
|
||||
|
||||
mb := bus.New()
|
||||
mgr := NewManager(mb)
|
||||
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
|
||||
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, false, true, behavior)
|
||||
|
||||
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
got, ok := mb.SubscribeOutbound(ctx)
|
||||
if !ok {
|
||||
t.Fatal("expected fallback quick acknowledgement")
|
||||
}
|
||||
if got.Content != "Fallback." {
|
||||
t.Fatalf("fallback content = %q, want Fallback.", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentEvent_QuickAckModeOffPreservesExplicitBlockReply(t *testing.T) {
|
||||
behavior := ResolvedChatBehavior{
|
||||
Enabled: true,
|
||||
QuickAck: ResolvedQuickAckConfig{
|
||||
Enabled: true,
|
||||
Mode: QuickAckModeOff,
|
||||
Templates: []string{"Fallback."},
|
||||
},
|
||||
}
|
||||
|
||||
mb := bus.New()
|
||||
mgr := NewManager(mb)
|
||||
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
|
||||
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, true, true, behavior)
|
||||
|
||||
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
|
||||
mgr.HandleAgentEvent(protocol.AgentEventBlockReply, "run-1", map[string]string{"content": "Explicit block reply."})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
got, ok := mb.SubscribeOutbound(ctx)
|
||||
if !ok {
|
||||
t.Fatal("expected explicit block reply outbound message")
|
||||
}
|
||||
if got.Content != "Explicit block reply." {
|
||||
t.Fatalf("explicit block reply content = %q", got.Content)
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
)
|
||||
|
||||
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."},
|
||||
},
|
||||
@@ -50,6 +52,68 @@ func TestResolveChatBehavior_InheritsGlobalAndChannelOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 disabled for non-streaming llm_generated mode")
|
||||
}
|
||||
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_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."
|
||||
@@ -84,9 +148,10 @@ func TestSplitFinalMessages_DoesNotSplitUnsafeMarkdown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPreviewChatBehavior_NoSideEffects(t *testing.T) {
|
||||
fixedMode := QuickAckModeFixedTemplate
|
||||
global := &config.ChatBehaviorConfig{
|
||||
Enabled: new(true),
|
||||
QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"Working."}},
|
||||
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &fixedMode, Templates: []string{"Working."}},
|
||||
FinalSplit: &config.FinalSplitConfig{Enabled: new(true), MinChars: new(10), MaxMessages: new(2)},
|
||||
}
|
||||
|
||||
@@ -99,18 +164,62 @@ func TestPreviewChatBehavior_NoSideEffects(t *testing.T) {
|
||||
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 TestManagerResolveChatBehavior_UsesChannelOverride(t *testing.T) {
|
||||
func TestPreviewChatBehavior_GeneratedModeReportsFallback(t *testing.T) {
|
||||
global := &config.ChatBehaviorConfig{
|
||||
Enabled: new(true),
|
||||
QuickAck: &config.QuickAckConfig{Enabled: new(true), Templates: []string{"global"}},
|
||||
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 != "" || got.Ack.FallbackContent != "Fallback." {
|
||||
t.Fatalf("Ack preview content/fallback = %q/%q, want empty/Fallback.", got.Ack.Content, got.Ack.FallbackContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewChatBehavior_GeneratedModeWithoutToolCallsReportsTemplateFallback(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 != QuickAckSourceTemplate {
|
||||
t.Fatalf("Ack preview = %+v, want template fallback decision", got.Ack)
|
||||
}
|
||||
if got.Ack.Content != "Fallback." || got.Ack.FallbackContent != "" {
|
||||
t.Fatalf("Ack preview content/fallback = %q/%q, want Fallback./empty", got.Ack.Content, got.Ack.FallbackContent)
|
||||
}
|
||||
}
|
||||
|
||||
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), Templates: []string{"channel"}},
|
||||
QuickAck: &config.QuickAckConfig{Enabled: new(true), Mode: &fixedMode, Templates: []string{"channel"}},
|
||||
}
|
||||
mgr := NewManager(bus.New())
|
||||
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test", behavior: override})
|
||||
|
||||
@@ -271,12 +271,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
}
|
||||
|
||||
// Handle block.reply: deliver intermediate assistant text to non-streaming channels.
|
||||
// Gated by BlockReplyEnabled (resolved from gateway + per-channel config at RegisterRun time).
|
||||
// Gated by explicit block_reply or generated-progress chat behavior.
|
||||
// Streaming channels already deliver via chunks, so skip to avoid double-delivery.
|
||||
if eventType == protocol.AgentEventBlockReply {
|
||||
if !rc.BlockReplyEnabled {
|
||||
return
|
||||
}
|
||||
content := extractPayloadString(payload, "content")
|
||||
if content == "" {
|
||||
return
|
||||
@@ -288,6 +285,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
if streaming {
|
||||
return // streaming already delivered via chunks
|
||||
}
|
||||
if !rc.BlockReplyEnabled && !ShouldDeliverGeneratedProgress(rc.ChatBehavior, streaming) {
|
||||
return
|
||||
}
|
||||
|
||||
m.cancelQuickAck(rc)
|
||||
rc.mu.Lock()
|
||||
|
||||
@@ -11,6 +11,7 @@ type ChatBehaviorConfig struct {
|
||||
// QuickAckConfig controls one short acknowledgement before longer non-streaming runs.
|
||||
type QuickAckConfig struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Mode *string `json:"mode,omitempty"`
|
||||
MinDelayMs *int `json:"min_delay_ms,omitempty"`
|
||||
Templates []string `json:"templates,omitempty"`
|
||||
}
|
||||
|
||||
@@ -72,14 +72,10 @@ func (m *ChatBehaviorMethods) handlePreview(_ context.Context, client *gateway.C
|
||||
} else {
|
||||
resolved = channels.ResolveChatBehavior(m.cfg.Gateway.ChatBehavior, nil)
|
||||
}
|
||||
preview := channels.ChatBehaviorPreview{
|
||||
Resolved: resolved,
|
||||
Split: channels.SplitPreview{
|
||||
Parts: channels.SplitFinalMessages(params.Content, resolved.FinalSplit),
|
||||
},
|
||||
}
|
||||
if channels.ShouldSendQuickAck(resolved, params.IsStreaming) {
|
||||
preview.Ack = channels.AckPreview{ShouldSend: true, Content: resolved.QuickAck.Templates[0]}
|
||||
}
|
||||
preview := channels.PreviewResolvedChatBehavior(resolved, channels.ChatBehaviorPreviewOptions{
|
||||
Content: params.Content,
|
||||
IsStreaming: params.IsStreaming,
|
||||
HasToolCalls: params.HasToolCalls,
|
||||
})
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, preview))
|
||||
}
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Characterization Tests"
|
||||
status: pending
|
||||
title: Characterization Tests
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ""
|
||||
effort: ''
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
|
||||
+5
-4
@@ -1,10 +1,11 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Config Contract"
|
||||
status: pending
|
||||
title: Config Contract
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ""
|
||||
dependencies: [1]
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Config Contract
|
||||
|
||||
+6
-4
@@ -1,10 +1,12 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Runtime Delivery"
|
||||
status: pending
|
||||
title: Runtime Delivery
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ""
|
||||
dependencies: [1, 2]
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 3: Runtime Delivery
|
||||
|
||||
+6
-4
@@ -1,10 +1,12 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Dashboard Controls"
|
||||
status: pending
|
||||
title: Dashboard Controls
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: ""
|
||||
dependencies: [2, 3]
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 2
|
||||
- 3
|
||||
---
|
||||
|
||||
# Phase 4: Dashboard Controls
|
||||
|
||||
+8
-4
@@ -1,10 +1,14 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Validation and Ship Handoff"
|
||||
status: pending
|
||||
title: Validation and Ship Handoff
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ""
|
||||
dependencies: [1, 2, 3, 4]
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
---
|
||||
|
||||
# Phase 5: Validation and Ship Handoff
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
---
|
||||
title: "Issue 118 LLM-Generated Channel Progress"
|
||||
description: "TDD plan for digitopvn/goclaw#118: make channel immediate/progress replies LLM-generated through the existing main-turn block.reply path, with fixed quick_ack templates retained only as fallback."
|
||||
status: pending
|
||||
title: Issue 118 LLM-Generated Channel Progress
|
||||
description: >-
|
||||
TDD plan for digitopvn/goclaw#118: make channel immediate/progress replies
|
||||
LLM-generated through the existing main-turn block.reply path, with fixed
|
||||
quick_ack templates retained only as fallback.
|
||||
status: completed
|
||||
priority: P2
|
||||
branch: "codex/issue-118-llm-generated-progress-messages"
|
||||
tags: [issue-118, channels, chat-behavior, block-reply, tdd]
|
||||
branch: codex/issue-118-llm-generated-progress-messages
|
||||
tags:
|
||||
- issue-118
|
||||
- channels
|
||||
- chat-behavior
|
||||
- block-reply
|
||||
- tdd
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: "2026-05-31T08:55:50.410Z"
|
||||
createdBy: "ck:plan"
|
||||
created: '2026-05-31T08:55:50.410Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
@@ -52,11 +60,11 @@ Explicitly out of scope:
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Characterization Tests](./phase-01-characterization-tests.md) | Pending |
|
||||
| 2 | [Config Contract](./phase-02-config-contract.md) | Pending |
|
||||
| 3 | [Runtime Delivery](./phase-03-runtime-delivery.md) | Pending |
|
||||
| 4 | [Dashboard Controls](./phase-04-dashboard-controls.md) | Pending |
|
||||
| 5 | [Validation and Ship Handoff](./phase-05-validation-and-ship-handoff.md) | Pending |
|
||||
| 1 | [Characterization Tests](./phase-01-characterization-tests.md) | Completed |
|
||||
| 2 | [Config Contract](./phase-02-config-contract.md) | Completed |
|
||||
| 3 | [Runtime Delivery](./phase-03-runtime-delivery.md) | Completed |
|
||||
| 4 | [Dashboard Controls](./phase-04-dashboard-controls.md) | Completed |
|
||||
| 5 | [Validation and Ship Handoff](./phase-05-validation-and-ship-handoff.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -133,9 +133,13 @@
|
||||
"behavior.chatEnabled": "Enable channel delivery behavior",
|
||||
"behavior.chatEnabledHint": "Applies globally, with optional per-channel overrides.",
|
||||
"behavior.quickAck": "Quick acknowledgement",
|
||||
"behavior.quickAckHint": "Send one short acknowledgement before longer non-streaming runs.",
|
||||
"behavior.quickAckDelay": "Delay (ms)",
|
||||
"behavior.quickAckTemplates": "Templates",
|
||||
"behavior.quickAckHint": "Prefer the main LLM's block reply; use templates only as fallback.",
|
||||
"behavior.quickAckMode": "Mode",
|
||||
"behavior.quickAckMode.llm_generated": "LLM-generated with fallback",
|
||||
"behavior.quickAckMode.fixed_template": "Fixed template",
|
||||
"behavior.quickAckMode.off": "Off",
|
||||
"behavior.quickAckDelay": "Fallback delay (ms)",
|
||||
"behavior.quickAckTemplates": "Fallback templates",
|
||||
"behavior.finalSplit": "Final reply splitting",
|
||||
"behavior.finalSplitHint": "Split long final replies into safe paragraph messages.",
|
||||
"behavior.finalSplitMinChars": "Min chars",
|
||||
@@ -143,6 +147,7 @@
|
||||
"behavior.finalSplitDelay": "Delay (ms)",
|
||||
"behavior.preview": "Preview",
|
||||
"behavior.previewAck": "Ack",
|
||||
"behavior.previewGeneratedAck": "Generated by main LLM; fallback: {{fallback}}",
|
||||
"behavior.previewNoAck": "No acknowledgement for this sample.",
|
||||
"behavior.previewParts": "{{count}} final message part",
|
||||
"behavior.previewParts_plural": "{{count}} final message parts",
|
||||
|
||||
@@ -133,9 +133,13 @@
|
||||
"behavior.chatEnabled": "Bật hành vi gửi tin theo kênh",
|
||||
"behavior.chatEnabledHint": "Áp dụng toàn cục, có thể ghi đè theo từng kênh.",
|
||||
"behavior.quickAck": "Xác nhận nhanh",
|
||||
"behavior.quickAckHint": "Gửi một tin xác nhận ngắn trước các lượt chạy không streaming lâu hơn.",
|
||||
"behavior.quickAckDelay": "Độ trễ (ms)",
|
||||
"behavior.quickAckTemplates": "Mẫu tin",
|
||||
"behavior.quickAckHint": "Ưu tiên block reply từ LLM chính; chỉ dùng mẫu tin làm fallback.",
|
||||
"behavior.quickAckMode": "Chế độ",
|
||||
"behavior.quickAckMode.llm_generated": "LLM sinh, có fallback",
|
||||
"behavior.quickAckMode.fixed_template": "Mẫu cố định",
|
||||
"behavior.quickAckMode.off": "Tắt",
|
||||
"behavior.quickAckDelay": "Độ trễ fallback (ms)",
|
||||
"behavior.quickAckTemplates": "Mẫu fallback",
|
||||
"behavior.finalSplit": "Tách phản hồi cuối",
|
||||
"behavior.finalSplitHint": "Tách phản hồi cuối dài thành các đoạn tin nhắn an toàn.",
|
||||
"behavior.finalSplitMinChars": "Ký tự tối thiểu",
|
||||
@@ -143,6 +147,7 @@
|
||||
"behavior.finalSplitDelay": "Độ trễ (ms)",
|
||||
"behavior.preview": "Xem trước",
|
||||
"behavior.previewAck": "Xác nhận",
|
||||
"behavior.previewGeneratedAck": "LLM chính sinh; fallback: {{fallback}}",
|
||||
"behavior.previewNoAck": "Mẫu này không gửi xác nhận.",
|
||||
"behavior.previewParts": "{{count}} phần phản hồi cuối",
|
||||
"behavior.pendingCompactionTitle": "Nén tin nhắn chờ",
|
||||
|
||||
@@ -133,9 +133,13 @@
|
||||
"behavior.chatEnabled": "启用频道投递行为",
|
||||
"behavior.chatEnabledHint": "全局生效,可按频道覆盖。",
|
||||
"behavior.quickAck": "快速确认",
|
||||
"behavior.quickAckHint": "在较长的非流式运行前发送一条简短确认。",
|
||||
"behavior.quickAckDelay": "延迟(毫秒)",
|
||||
"behavior.quickAckTemplates": "模板",
|
||||
"behavior.quickAckHint": "优先使用主 LLM 的 block reply;模板仅作为回退。",
|
||||
"behavior.quickAckMode": "模式",
|
||||
"behavior.quickAckMode.llm_generated": "LLM 生成并带回退",
|
||||
"behavior.quickAckMode.fixed_template": "固定模板",
|
||||
"behavior.quickAckMode.off": "关闭",
|
||||
"behavior.quickAckDelay": "回退延迟(毫秒)",
|
||||
"behavior.quickAckTemplates": "回退模板",
|
||||
"behavior.finalSplit": "最终回复拆分",
|
||||
"behavior.finalSplitHint": "将较长最终回复拆成安全的段落消息。",
|
||||
"behavior.finalSplitMinChars": "最小字符数",
|
||||
@@ -143,6 +147,7 @@
|
||||
"behavior.finalSplitDelay": "延迟(毫秒)",
|
||||
"behavior.preview": "预览",
|
||||
"behavior.previewAck": "确认",
|
||||
"behavior.previewGeneratedAck": "由主 LLM 生成;回退:{{fallback}}",
|
||||
"behavior.previewNoAck": "此示例不会发送确认。",
|
||||
"behavior.previewParts": "{{count}} 个最终回复片段",
|
||||
"behavior.pendingCompactionTitle": "待处理消息压缩",
|
||||
|
||||
@@ -26,9 +26,17 @@ const blockReplyOptions = [
|
||||
{ value: "false", label: "Disabled" },
|
||||
];
|
||||
|
||||
const quickAckModeOptions = [
|
||||
{ value: "inherit", label: "Inherit from gateway" },
|
||||
{ value: "llm_generated", label: "LLM-generated with fallback" },
|
||||
{ value: "fixed_template", label: "Fixed template" },
|
||||
{ value: "off", label: "Off" },
|
||||
];
|
||||
|
||||
const chatBehaviorOverrideFields: FieldDef[] = [
|
||||
{ key: "chat_behavior.enabled", label: "Human-like Delivery", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override gateway quick acknowledgement and final reply splitting." },
|
||||
{ key: "chat_behavior.quick_ack.enabled", label: "Quick Acknowledgement", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override quick acknowledgement for this channel." },
|
||||
{ key: "chat_behavior.quick_ack.mode", label: "Quick Ack Mode", type: "select", options: quickAckModeOptions, defaultValue: "inherit", help: "Generated uses the main LLM block reply; templates are fallback only." },
|
||||
{ key: "chat_behavior.final_split.enabled", label: "Final Reply Splitting", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Override final multi-message splitting for this channel." },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,13 +6,18 @@ import { useWs } from "@/hooks/use-ws";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const quickAckModeValues = ["llm_generated", "fixed_template", "off"] as const;
|
||||
type QuickAckMode = (typeof quickAckModeValues)[number];
|
||||
|
||||
export interface ChatBehaviorValues {
|
||||
enabled?: boolean;
|
||||
quick_ack?: {
|
||||
enabled?: boolean;
|
||||
mode?: QuickAckMode;
|
||||
min_delay_ms?: number;
|
||||
templates?: string[];
|
||||
};
|
||||
@@ -25,7 +30,7 @@ export interface ChatBehaviorValues {
|
||||
}
|
||||
|
||||
interface PreviewResponse {
|
||||
ack?: { shouldSend?: boolean; content?: string };
|
||||
ack?: { shouldSend?: boolean; content?: string; fallbackContent?: string; source?: string };
|
||||
split?: { parts?: string[] };
|
||||
}
|
||||
|
||||
@@ -101,6 +106,23 @@ export function BehaviorChatCard({ value, onChange }: Props) {
|
||||
disabled={!value.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="chat-behavior-ack-mode">{t("behavior.quickAckMode")}</Label>
|
||||
<Select
|
||||
value={value.quick_ack?.mode ?? "llm_generated"}
|
||||
onValueChange={(mode) => patchAck({ mode: mode as QuickAckMode })}
|
||||
disabled={!value.enabled}
|
||||
>
|
||||
<SelectTrigger id="chat-behavior-ack-mode" className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{quickAckModeValues.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>{t(`behavior.quickAckMode.${mode}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="chat-behavior-ack-delay">{t("behavior.quickAckDelay")}</Label>
|
||||
<Input
|
||||
@@ -150,7 +172,7 @@ export function BehaviorChatCard({ value, onChange }: Props) {
|
||||
{t("behavior.preview")}
|
||||
</div>
|
||||
<div className="space-y-2 text-muted-foreground">
|
||||
<p>{preview?.ack?.shouldSend ? `${t("behavior.previewAck")}: ${preview.ack.content}` : t("behavior.previewNoAck")}</p>
|
||||
<p>{formatAckPreview(preview, t)}</p>
|
||||
<p>{t("behavior.previewParts", { count: preview?.split?.parts?.length ?? 1 })}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +181,15 @@ export function BehaviorChatCard({ value, onChange }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatAckPreview(preview: PreviewResponse | null, t: (key: string, options?: any) => string) {
|
||||
const ack = preview?.ack;
|
||||
if (!ack?.shouldSend) return t("behavior.previewNoAck");
|
||||
if (ack.source === "generated") {
|
||||
return t("behavior.previewGeneratedAck", { fallback: ack.fallbackContent ?? "" });
|
||||
}
|
||||
return `${t("behavior.previewAck")}: ${ack.content ?? ""}`;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -130,6 +130,7 @@ function normalizeChatBehavior(value: any): ChatBehaviorValues {
|
||||
enabled: value?.enabled ?? false,
|
||||
quick_ack: {
|
||||
enabled: value?.quick_ack?.enabled ?? true,
|
||||
mode: value?.quick_ack?.mode ?? "llm_generated",
|
||||
min_delay_ms: value?.quick_ack?.min_delay_ms ?? 1000,
|
||||
templates: value?.quick_ack?.templates ?? ["Got it. Working on it..."],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user