mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 14:16:30 +00:00
Merge pull request #119 from digitopvn/codex/issue-118-llm-generated-progress-messages
feat(channels): use generated quick ack progress
This commit is contained in:
23 files changed
+935
-40
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))
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Characterization Tests
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ''
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Characterization Tests
|
||||
|
||||
## Overview
|
||||
|
||||
Lock current behavior before changing semantics. These tests should fail only where issue #118 intentionally changes default quick acknowledgement from fixed templates to generated progress plus fallback.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: prove current template-only quick ack behavior and `block.reply` delivery gates.
|
||||
- Functional: prove existing final dedup depends on delivered `block.reply`.
|
||||
- Non-functional: tests must not use external LLM calls or channel network calls.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use existing unit-test surfaces:
|
||||
- `internal/channels/chat_behavior_test.go` for resolver and preview decisions.
|
||||
- `internal/channels/chat_behavior_events_test.go` for timer, fallback, streaming, and block reply delivery.
|
||||
- Existing pipeline tests in `internal/pipeline/stages_test.go` already cover `block.reply` only when tool calls exist; add only if a behavior gap is discovered.
|
||||
|
||||
No DB, migration, or integration fixture is needed.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/channels/chat_behavior_test.go`
|
||||
- Modify: `internal/channels/chat_behavior_events_test.go`
|
||||
- Optional modify: `cmd/gateway_consumer_normal_test.go` if a focused final-dedup test already exists nearby
|
||||
- Read: `internal/pipeline/think_stage.go`
|
||||
- Read: `internal/agent/loop_pipeline_adapter.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add resolver tests showing old default template behavior and the intended new default mode contract.
|
||||
2. Add event tests for generated progress canceling fallback before fallback timer sends.
|
||||
3. Add event tests for fallback sending only when no `block.reply` has been delivered.
|
||||
4. Add streaming guard test: generated progress/fallback must not duplicate streaming chunks.
|
||||
5. Add compatibility tests for explicit fixed-template mode so users can keep old behavior.
|
||||
6. Run the focused Go test package and confirm new tests fail before implementation.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Tests describe generated-first, template-fallback behavior.
|
||||
- [ ] Tests prove no separate LLM call is required.
|
||||
- [ ] Tests and review prove this issue adds no new persistence path.
|
||||
- [ ] Tests fail before Phase 2/3 implementation and pass after.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Risk: tests accidentally assert impossible "instant" LLM output. Mitigation: state generated progress depends on main-turn `block.reply`, not pre-LLM response.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Config Contract
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Config Contract
|
||||
|
||||
## Overview
|
||||
|
||||
Add an explicit quick acknowledgement mode contract so fixed templates stop being the default behavior while old configurations remain representable.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: default `chat_behavior.quick_ack` mode is generated-first with template fallback.
|
||||
- Functional: explicit fixed-template mode preserves old behavior.
|
||||
- Functional: existing `templates` field remains the fallback template list.
|
||||
- Functional: config preview returns enough metadata for UI to explain generated vs fallback decisions.
|
||||
- Non-functional: additive JSON config only; no migration.
|
||||
|
||||
## Architecture
|
||||
|
||||
Proposed additive shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"chat_behavior": {
|
||||
"enabled": true,
|
||||
"quick_ack": {
|
||||
"enabled": true,
|
||||
"mode": "llm_generated",
|
||||
"min_delay_ms": 1000,
|
||||
"templates": ["Got it. Working on it..."]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mode semantics:
|
||||
- `llm_generated`: generated `block.reply` is preferred; `templates` are fallback if no generated progress arrives before `min_delay_ms`.
|
||||
- `fixed_template`: old behavior; send template after `min_delay_ms` when eligible.
|
||||
- `off`: disables chat-behavior quick acknowledgement only; explicit `gateway.block_reply=true` must still preserve existing block reply delivery.
|
||||
|
||||
Implementation detail: use string constants in `internal/channels/chat_behavior.go`; keep config structs string-based to avoid custom JSON code.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/config/config_channels.go`
|
||||
- Modify: `internal/channels/chat_behavior.go`
|
||||
- Modify: `internal/gateway/methods/chat_behavior.go`
|
||||
- Modify: `internal/channels/chat_behavior_test.go`
|
||||
- Read: `cmd/gateway_system_config_sync.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `Mode *string json:"mode,omitempty"` to `QuickAckConfig`.
|
||||
2. Add `Mode string` to `ResolvedQuickAckConfig`.
|
||||
3. Define accepted modes near existing quick ack defaults.
|
||||
4. Update `ResolveChatBehavior` to default quick ack mode to `llm_generated` while retaining fallback templates.
|
||||
5. Treat nil/empty mode as `llm_generated`, even when legacy configs have `templates`; this is the requested default change. Treat unknown strings as `llm_generated` and test the fallback.
|
||||
6. Update `ShouldSendQuickAck` or split it into clearer helpers if needed:
|
||||
- fallback timer eligibility
|
||||
- fixed template delivery eligibility
|
||||
- generated progress delivery eligibility
|
||||
7. Update preview response so UI can show whether acknowledgement is generated-first, fixed-template, or off.
|
||||
8. Keep `templates` cleaning behavior but document it as fallback templates.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Existing JSON config remains backward compatible.
|
||||
- [ ] Default resolver no longer treats fixed template as the primary quick ack.
|
||||
- [ ] Unknown/empty templates still resolve to the fallback default string.
|
||||
- [ ] Preview can distinguish generated-first from fixed-template behavior.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Risk: changing default mode breaks users expecting old fixed-template quick ack. Mitigation: expose explicit `fixed_template` mode and keep `templates` field semantics intact.
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
---
|
||||
phase: 3
|
||||
title: Runtime Delivery
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 3: Runtime Delivery
|
||||
|
||||
## Overview
|
||||
|
||||
Route generated progress through existing `block.reply` channel delivery and use fixed templates only as the fallback path.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: generated `block.reply` can be delivered for chat behavior even when global `gateway.block_reply` is false.
|
||||
- Functional: explicit `gateway.block_reply=true` preserves current full block reply behavior.
|
||||
- Functional: fallback template cancels when a generated `block.reply` arrives.
|
||||
- Functional: streaming channels skip generated progress/fallback to avoid duplicate chunks.
|
||||
- Non-functional: do not store generated progress and do not create another LLM call.
|
||||
- Non-functional: do not change existing run timeline recorder behavior.
|
||||
|
||||
## Architecture
|
||||
|
||||
Current flow:
|
||||
- `cmd/gateway_consumer_normal.go:242` resolves global/per-channel `block_reply`.
|
||||
- `cmd/gateway_consumer_normal.go:245` resolves `chat_behavior`.
|
||||
- `internal/channels/events.go:276` drops `block.reply` unless `RunContext.BlockReplyEnabled` is true.
|
||||
- `internal/channels/events.go:292` cancels quick ack when a `block.reply` is delivered.
|
||||
|
||||
Target flow:
|
||||
- Register runs with both explicit `block_reply` and chat behavior.
|
||||
- In channel event handling, allow `block.reply` delivery when either:
|
||||
- explicit block reply is enabled, or
|
||||
- chat behavior quick ack mode is `llm_generated` and non-streaming.
|
||||
- In generated-progress mode, mark `blockReplySent` and cancel fallback timer after a generated message is published.
|
||||
- Keep final dedup aligned with actual delivered generated progress, not only explicit `gateway.block_reply`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/channels/events.go`
|
||||
- Modify: `internal/channels/runs.go`
|
||||
- Modify: `internal/channels/manager.go`
|
||||
- Modify: `cmd/gateway_consumer_normal.go`
|
||||
- Modify: `internal/channels/chat_behavior_events_test.go`
|
||||
- Read only: `internal/agent/run_timeline_recorder.go`
|
||||
- Read: `internal/pipeline/think_stage.go`
|
||||
- Read: `internal/pipeline/observe_stage.go`
|
||||
- Read: `internal/agent/loop_pipeline_adapter.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add runtime helper(s) that answer:
|
||||
- should schedule fallback template?
|
||||
- should deliver generated `block.reply`?
|
||||
- was a generated/fixed interim message delivered and should final dedup run?
|
||||
2. Update run registration context if a new resolved flag is needed; avoid shared mutable global state.
|
||||
3. Change `AgentEventBlockReply` handling to use generated-progress eligibility as well as explicit block reply.
|
||||
4. Keep streaming guard before publishing outbound.
|
||||
5. Ensure fallback timer is canceled when generated progress publishes.
|
||||
6. Ensure final dedup checks actual delivered interim reply when chat behavior generated mode delivered one.
|
||||
7. Keep retry/tool status messages unchanged unless tests show direct conflict.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Non-streaming channel can receive LLM-generated progress from main-turn `block.reply` with `gateway.block_reply=false`.
|
||||
- [ ] Fallback template sends only if no generated progress was delivered before delay.
|
||||
- [ ] Streaming channel gets no duplicate progress messages.
|
||||
- [ ] Existing explicit block reply behavior and final dedup remain covered.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Risk: generated progress could duplicate final answer if final dedup remains tied to explicit block reply only. Mitigation: base dedup on actual delivered interim reply count/content.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Dashboard Controls
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 2
|
||||
- 3
|
||||
---
|
||||
|
||||
# Phase 4: Dashboard Controls
|
||||
|
||||
## Overview
|
||||
|
||||
Update the Behavior UI so the default is generated progress, and fallback templates are presented as fallback, not the main acknowledgement content.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: global Behavior card can select quick ack mode.
|
||||
- Functional: per-channel override schema can override quick ack mode where current quick ack enabled override exists.
|
||||
- Functional: UI preview explains generated-first vs fallback/fixed behavior.
|
||||
- Non-functional: all new user-facing strings use existing i18n namespace and en/vi/zh locale files.
|
||||
- Non-functional: keep component files under the repo's 200-line guidance or split a focused child component.
|
||||
|
||||
## Architecture
|
||||
|
||||
Current UI:
|
||||
- `normalizeChatBehavior` defaults templates to `"Got it. Working on it..."` in `ui/web/src/pages/config/sections/behavior-section.tsx:128`.
|
||||
- `BehaviorChatCard` labels templates as quick ack templates in `ui/web/src/pages/config/sections/behavior-chat-card.tsx:115`.
|
||||
- Per-channel schema only overrides `chat_behavior.quick_ack.enabled` in `ui/web/src/pages/channels/channel-schemas.ts:31`.
|
||||
|
||||
Target UI:
|
||||
- Add a mode control with values generated-first, fixed-template, off.
|
||||
- Rename template textarea copy to fallback templates.
|
||||
- Keep delay field as fallback/fixed delay.
|
||||
- Preserve mobile input font size rule: `text-base md:text-sm` on inputs/selects if custom components require class names.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `ui/web/src/pages/config/sections/behavior-section.tsx`
|
||||
- Modify: `ui/web/src/pages/config/sections/behavior-chat-card.tsx`
|
||||
- Modify: `ui/web/src/pages/channels/channel-schemas.ts`
|
||||
- Modify: `ui/web/src/i18n/locales/en/config.json`
|
||||
- Modify: `ui/web/src/i18n/locales/vi/config.json`
|
||||
- Modify: `ui/web/src/i18n/locales/zh/config.json`
|
||||
- Optional create: a focused child component under `ui/web/src/pages/config/sections/` if `behavior-chat-card.tsx` would exceed 200 lines
|
||||
- Modify: `docs/05-channels-messaging.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add mode to `ChatBehaviorValues`.
|
||||
2. Normalize default mode to `llm_generated`.
|
||||
3. Add mode control and copy that explains no extra LLM call.
|
||||
4. Rename template labels/hints to fallback template language.
|
||||
5. Update preview rendering for generated-first/fallback/fixed states.
|
||||
6. Add per-channel schema override for `chat_behavior.quick_ack.mode`.
|
||||
7. Add i18n keys to all three config locale files.
|
||||
8. Update channel messaging docs with new semantics and explicit no-storage note.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Dashboard no longer presents fixed template as default immediate response.
|
||||
- [ ] Config payload includes `quick_ack.mode` when changed.
|
||||
- [ ] Per-channel override can inherit/generated/fixed/off.
|
||||
- [ ] All new UI strings are localized.
|
||||
- [ ] Component size guidance is respected or consciously documented.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Risk: adding controls could bloat the existing card. Mitigation: split a small quick-ack settings component if the file crosses 200 lines.
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
phase: 5
|
||||
title: Validation and Ship Handoff
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: ''
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
---
|
||||
|
||||
# Phase 5: Validation and Ship Handoff
|
||||
|
||||
## Overview
|
||||
|
||||
Run focused and broad validation, update plan status/docs, ship a beta PR, run PR review/fix loop, and report back on issue #118.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: every acceptance criterion in `plan.md` is validated by tests, build, or explicit review.
|
||||
- Functional: issue #118 gets plan comment, implementation report, branch/PR link, and label transition.
|
||||
- Non-functional: do not push secrets or unrelated changes.
|
||||
|
||||
## Architecture
|
||||
|
||||
This phase is process-only. It uses the existing GitHub CLI workflow and repo validation commands. Beta shipping targets `dev` because README describes `dev` branch pushes as beta release path.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: plan status files in this plan directory
|
||||
- Modify: docs only if implementation changes public channel behavior
|
||||
- No runtime files unless review finds actionable issues
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Run focused Go tests:
|
||||
`go test ./internal/channels ./internal/config ./internal/gateway/methods`
|
||||
2. Run SQLite-tag focused tests:
|
||||
`go test -tags sqliteonly ./internal/channels ./internal/config ./internal/gateway/methods`
|
||||
3. Run broad compile/static checks:
|
||||
`go build ./...`
|
||||
`go build -tags sqliteonly ./...`
|
||||
`go vet ./...`
|
||||
4. Run web validation:
|
||||
`cd ui/web && pnpm test -- --run`
|
||||
`cd ui/web && pnpm build`
|
||||
5. Run `git diff --check` and inspect `git status --short`.
|
||||
6. Commit and push implementation with `ck:git cp` semantics.
|
||||
7. Create beta PR to `dev` and link issue #118.
|
||||
8. Run review-pr fix loop until no actionable findings remain.
|
||||
9. Comment issue #118 with implementation summary, PR URL, validation status, and final label update.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All focused tests pass.
|
||||
- [ ] PG and SQLite build pass.
|
||||
- [ ] Web build passes.
|
||||
- [ ] PR exists against `dev`.
|
||||
- [ ] Review/fix loop reports approve or no actionable findings.
|
||||
- [ ] Issue label moves from `ready to implement` to `ready to ship beta`.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Risk: broad integration/race tests require local services and may be unavailable. Mitigation: run all local deterministic checks; report any unavailable service-backed checks honestly.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
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
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-05-31T08:55:50.410Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Issue 118 LLM-Generated Channel Progress
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the approved cheapest path for issue #118.
|
||||
|
||||
Decision locked by user:
|
||||
- No separate LLM call for immediate/progress messages.
|
||||
- Use the existing main-turn `block.reply` event as the generated channel progress message.
|
||||
- Keep `quick_ack.templates` only as fallback.
|
||||
- Do not store progress messages.
|
||||
|
||||
Hard product constraint: without a separate LLM call, GoClaw cannot guarantee a natural LLM-generated message before the main model emits content. The generated progress message is available when the main LLM emits assistant content before tool calls; otherwise a configured fixed template fallback may fire after the fallback delay.
|
||||
|
||||
Current implementation facts:
|
||||
- `QuickAckConfig` has `enabled`, `min_delay_ms`, and `templates` only, so mode/fallback semantics need an additive config field in `internal/config/config_channels.go:11`.
|
||||
- `ResolveChatBehavior` currently defaults templates to `"Got it. Working on it..."` and `ShouldSendQuickAck` only checks enabled plus non-streaming in `internal/channels/chat_behavior.go:57` and `internal/channels/chat_behavior.go:140`.
|
||||
- `run.started` schedules quick ack immediately via `internal/channels/events.go:42`.
|
||||
- `block.reply` channel delivery is currently gated only by resolved `BlockReplyEnabled` in `internal/channels/events.go:276`.
|
||||
- Main-turn generated content already emits `block.reply` from tool iterations in `internal/pipeline/think_stage.go:148`, then sanitizes in `internal/agent/loop_pipeline_adapter.go:107`.
|
||||
- Final dedup depends on `blockReplyEnabled` in `cmd/gateway_consumer_normal.go:542`.
|
||||
- `internal/agent/run_timeline_recorder.go:137` already maps existing `block.reply` events to assistant-message timeline items. This issue must not add new persistence or broaden recorder behavior; existing timeline behavior is out of scope unless tests show a direct regression.
|
||||
|
||||
Scope:
|
||||
- Backend config contract and resolver.
|
||||
- Channel event delivery semantics.
|
||||
- Preview API and dashboard controls.
|
||||
- Documentation and issue handoff.
|
||||
|
||||
Explicitly out of scope:
|
||||
- New LLM provider call.
|
||||
- DB schema, timeline/archive persistence, or message history storage for progress messages.
|
||||
- Changing existing run timeline recorder semantics.
|
||||
- Raw chain-of-thought or tool trace exposure.
|
||||
- Per-agent prompt rewriting beyond existing main LLM output.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 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
|
||||
|
||||
- GitHub issue: `digitopvn/goclaw#118`
|
||||
- Related existing plan: `../260529-1210-human-like-channel-chat-behavior/plan.md`
|
||||
- Existing generated-content source: `internal/pipeline/think_stage.go`, `internal/agent/loop_pipeline_adapter.go`, `internal/pipeline/observe_stage.go`
|
||||
- Existing channel runtime: `internal/channels/events.go`, `internal/channels/runs.go`, `cmd/gateway_consumer_normal.go`
|
||||
- Existing config/runtime contract: `internal/config/config_channels.go`, `internal/channels/chat_behavior.go`, `internal/gateway/methods/chat_behavior.go`
|
||||
- Existing web config UI: `ui/web/src/pages/config/sections/behavior-section.tsx`, `ui/web/src/pages/config/sections/behavior-chat-card.tsx`, `ui/web/src/pages/channels/channel-schemas.ts`
|
||||
- Existing docs: `docs/05-channels-messaging.md`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Default enabled chat behavior prefers LLM-generated progress from main-turn `block.reply`, not fixed templates.
|
||||
- [ ] No extra LLM request is introduced.
|
||||
- [ ] Fixed templates remain configurable fallback only.
|
||||
- [ ] Fallback does not fire after a generated `block.reply` has already been delivered.
|
||||
- [ ] Streaming channels still avoid duplicate progress delivery.
|
||||
- [ ] Existing explicit `gateway.block_reply` behavior and final dedup continue to work.
|
||||
- [ ] Preview API describes generated-vs-fallback behavior without sending messages.
|
||||
- [ ] Dashboard config reflects generated default, fallback template semantics, and all new UI text is localized in en/vi/zh.
|
||||
- [ ] No new DB/archive/session-history storage is added for progress messages by this issue.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
go test ./internal/channels ./internal/config ./internal/gateway/methods
|
||||
go test -tags sqliteonly ./internal/channels ./internal/config ./internal/gateway/methods
|
||||
go build ./...
|
||||
go build -tags sqliteonly ./...
|
||||
go vet ./...
|
||||
cd ui/web && pnpm test -- --run
|
||||
cd ui/web && pnpm build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. User selected the no-extra-LLM, main-turn `block.reply`, no-progress-storage path.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Issue 118 Plan Redteam
|
||||
|
||||
Plan: `plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md`
|
||||
Date: 2026-05-31
|
||||
|
||||
## Findings
|
||||
|
||||
### Fixed
|
||||
|
||||
1. Persistence wording was too absolute.
|
||||
- Evidence: `internal/agent/run_timeline_recorder.go:137` already maps existing `block.reply` events to assistant-message timeline items.
|
||||
- Risk: plan could promise "no progress messages are stored" while existing event recorder already handles `block.reply`.
|
||||
- Fix: plan now says this issue adds no new persistence and does not broaden recorder behavior.
|
||||
|
||||
2. `off` mode semantics could accidentally disable explicit `gateway.block_reply`.
|
||||
- Evidence: `internal/channels/events.go:276` currently gates block replies by `RunContext.BlockReplyEnabled`, independent from chat behavior.
|
||||
- Risk: quick ack mode `off` could be over-implemented as a global block reply kill switch.
|
||||
- Fix: phase 02 now says `off` only disables chat-behavior quick acknowledgement; explicit `gateway.block_reply=true` must keep working.
|
||||
|
||||
3. Legacy nil mode behavior needed a firm decision.
|
||||
- Evidence: current `QuickAckConfig` has no mode in `internal/config/config_channels.go:11`.
|
||||
- Risk: implementation could infer old fixed-template behavior from non-empty `templates`, silently preserving the old default.
|
||||
- Fix: phase 02 now says nil/empty mode resolves to `llm_generated`, even when legacy configs have templates. This is the requested default change.
|
||||
|
||||
## Remaining Risks
|
||||
|
||||
- Generated progress cannot be guaranteed before tool execution without a separate LLM call. Plan states this explicitly.
|
||||
- Fallback template and later generated progress may both be visible in long runs. Implementation must keep this bounded by existing one-ack behavior plus existing `block.reply` semantics.
|
||||
- UI work may push `behavior-chat-card.tsx` past 200 lines. Phase 04 calls for a focused split if needed.
|
||||
|
||||
## Verdict
|
||||
|
||||
Plan is ready to implement after the fixed findings above.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Issue 118 Plan Validation
|
||||
|
||||
Plan: `plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md`
|
||||
Date: 2026-05-31
|
||||
|
||||
## User Decisions Checked
|
||||
|
||||
- No separate LLM call: preserved in overview, phases 01, 03, and validation criteria.
|
||||
- Use main-turn `block.reply`: preserved in overview and runtime phase.
|
||||
- Do not store progress messages: plan now scopes this as no new persistence or recorder broadening.
|
||||
- Templates fallback only: preserved through `llm_generated` mode and `fixed_template` compatibility.
|
||||
|
||||
## Code Claims Checked
|
||||
|
||||
- `QuickAckConfig` lacks mode today: verified in `internal/config/config_channels.go:11`.
|
||||
- Default template is currently fixed: verified in `internal/channels/chat_behavior.go:14` and resolver defaults at `internal/channels/chat_behavior.go:57`.
|
||||
- `run.started` schedules quick ack: verified in `internal/channels/events.go:42`.
|
||||
- `block.reply` channel delivery is gated by `BlockReplyEnabled`: verified in `internal/channels/events.go:276`.
|
||||
- Main LLM content emits `block.reply` on tool iterations: verified in `internal/pipeline/think_stage.go:148`.
|
||||
- Sanitization happens before emitting `block.reply`: verified in `internal/agent/loop_pipeline_adapter.go:107`.
|
||||
- Final dedup currently keys off explicit block reply enablement: verified in `cmd/gateway_consumer_normal.go:542`.
|
||||
- Existing timeline recorder handles `block.reply`: verified in `internal/agent/run_timeline_recorder.go:137`.
|
||||
|
||||
## Whole-Plan Consistency Sweep
|
||||
|
||||
- No stale claim that generated response is guaranteed "instant".
|
||||
- No plan step creates a DB migration or new storage table.
|
||||
- No plan step adds a provider or second LLM request.
|
||||
- Backend, UI, docs, tests, and ship handoff are represented.
|
||||
- Open questions are resolved by the user's selected direction.
|
||||
|
||||
## Validation Commands for Implementation
|
||||
|
||||
```bash
|
||||
go test ./internal/channels ./internal/config ./internal/gateway/methods
|
||||
go test -tags sqliteonly ./internal/channels ./internal/config ./internal/gateway/methods
|
||||
go build ./...
|
||||
go build -tags sqliteonly ./...
|
||||
go vet ./...
|
||||
cd ui/web && pnpm test -- --run
|
||||
cd ui/web && pnpm build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -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": "待处理消息压缩",
|
||||
|
||||
@@ -134,7 +134,12 @@ export function ChannelInstanceFormDialog({
|
||||
setConfigValues((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const coerceBoolSelects = (cfg: Record<string, unknown>, schema: FieldDef[]) => {
|
||||
const coerceSelects = (cfg: Record<string, unknown>, schema: FieldDef[]) => {
|
||||
const selectKeys = new Set(schema.filter((f) => f.type === "select").map((f) => f.key));
|
||||
for (const key of selectKeys) {
|
||||
if (cfg[key] === "inherit") delete cfg[key];
|
||||
}
|
||||
|
||||
const boolSelectKeys = new Set(
|
||||
schema.filter((f) => f.type === "select" && f.options?.some((o) => o.value === "true")).map((f) => f.key),
|
||||
);
|
||||
@@ -159,7 +164,7 @@ export function ChannelInstanceFormDialog({
|
||||
const cleanConfig = Object.fromEntries(
|
||||
Object.entries(configValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
|
||||
);
|
||||
coerceBoolSelects(cleanConfig, configSchema[values.channelType] ?? []);
|
||||
coerceSelects(cleanConfig, configSchema[values.channelType] ?? []);
|
||||
|
||||
// Drop the legacy bitrix24 public_url field if it leaked from an older
|
||||
// channel row's config — gateway now derives the URL from the portal's
|
||||
@@ -244,7 +249,7 @@ export function ChannelInstanceFormDialog({
|
||||
const cleanConfig = Object.fromEntries(
|
||||
Object.entries(configValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
|
||||
);
|
||||
coerceBoolSelects(cleanConfig, configSchema[channelType] ?? []);
|
||||
coerceSelects(cleanConfig, configSchema[channelType] ?? []);
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
|
||||
@@ -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