diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 68b53142..99d1d7f9 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -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{ diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index 65e8fa46..83c26476 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -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 | diff --git a/internal/channels/chat_behavior.go b/internal/channels/chat_behavior.go index b35bff67..df853c11 100644 --- a/internal/channels/chat_behavior.go +++ b/internal/channels/chat_behavior.go @@ -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 { diff --git a/internal/channels/chat_behavior_events_test.go b/internal/channels/chat_behavior_events_test.go index 6eb84449..442e29e5 100644 --- a/internal/channels/chat_behavior_events_test.go +++ b/internal/channels/chat_behavior_events_test.go @@ -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) + } +} diff --git a/internal/channels/chat_behavior_test.go b/internal/channels/chat_behavior_test.go index faf2bd10..8c71f513 100644 --- a/internal/channels/chat_behavior_test.go +++ b/internal/channels/chat_behavior_test.go @@ -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}) diff --git a/internal/channels/events.go b/internal/channels/events.go index e3fc7b8f..8b71741e 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -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() diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 0a703956..35d60d7f 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -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"` } diff --git a/internal/gateway/methods/chat_behavior.go b/internal/gateway/methods/chat_behavior.go index f87ec830..87397256 100644 --- a/internal/gateway/methods/chat_behavior.go +++ b/internal/gateway/methods/chat_behavior.go @@ -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)) } diff --git a/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-01-characterization-tests.md b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-01-characterization-tests.md new file mode 100644 index 00000000..0b7c6fa1 --- /dev/null +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-01-characterization-tests.md @@ -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. diff --git a/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-02-config-contract.md b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-02-config-contract.md new file mode 100644 index 00000000..c773ceea --- /dev/null +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-02-config-contract.md @@ -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. diff --git a/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-03-runtime-delivery.md b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-03-runtime-delivery.md new file mode 100644 index 00000000..b2b50109 --- /dev/null +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-03-runtime-delivery.md @@ -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. diff --git a/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-04-dashboard-controls.md b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-04-dashboard-controls.md new file mode 100644 index 00000000..b4c4bfc4 --- /dev/null +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-04-dashboard-controls.md @@ -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. diff --git a/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-05-validation-and-ship-handoff.md b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-05-validation-and-ship-handoff.md new file mode 100644 index 00000000..6a3b4140 --- /dev/null +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/phase-05-validation-and-ship-handoff.md @@ -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. diff --git a/plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md b/plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md new file mode 100644 index 00000000..0eec3d69 --- /dev/null +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md @@ -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. diff --git a/plans/reports/260531-1555-issue-118-plan-redteam.md b/plans/reports/260531-1555-issue-118-plan-redteam.md new file mode 100644 index 00000000..27bff49a --- /dev/null +++ b/plans/reports/260531-1555-issue-118-plan-redteam.md @@ -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. diff --git a/plans/reports/260531-1555-issue-118-plan-validation.md b/plans/reports/260531-1555-issue-118-plan-validation.md new file mode 100644 index 00000000..7b9f77d4 --- /dev/null +++ b/plans/reports/260531-1555-issue-118-plan-validation.md @@ -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. diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 5b5c079c..03d56b46 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index 4207d0ea..cfd0574b 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -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ờ", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index e3749f4a..e702e38f 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -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": "待处理消息压缩", diff --git a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx index 50070318..104d5c1a 100644 --- a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx +++ b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx @@ -134,7 +134,12 @@ export function ChannelInstanceFormDialog({ setConfigValues((prev) => ({ ...prev, [key]: value })); }, []); - const coerceBoolSelects = (cfg: Record, schema: FieldDef[]) => { + const coerceSelects = (cfg: Record, 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 { diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index 8bb00243..f8b0bf72 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -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." }, ]; diff --git a/ui/web/src/pages/config/sections/behavior-chat-card.tsx b/ui/web/src/pages/config/sections/behavior-chat-card.tsx index 6a5ddb47..f0fa6b5a 100644 --- a/ui/web/src/pages/config/sections/behavior-chat-card.tsx +++ b/ui/web/src/pages/config/sections/behavior-chat-card.tsx @@ -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} /> +
+ + +
-

{preview?.ack?.shouldSend ? `${t("behavior.previewAck")}: ${preview.ack.content}` : t("behavior.previewNoAck")}

+

{formatAckPreview(preview, t)}

{t("behavior.previewParts", { count: preview?.split?.parts?.length ?? 1 })}

@@ -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 (
diff --git a/ui/web/src/pages/config/sections/behavior-section.tsx b/ui/web/src/pages/config/sections/behavior-section.tsx index 6dd32554..7cedc08c 100644 --- a/ui/web/src/pages/config/sections/behavior-section.tsx +++ b/ui/web/src/pages/config/sections/behavior-section.tsx @@ -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..."], },