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:
Duy /zuey/ authored and GitHub committed 2026-05-31 16:57:01 +07:00
commit 0b5f64e130
23 files changed
+935 -40

No files matched your search

+3 -2
View File
@@ -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{
+6 -1
View File
@@ -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 |
+88 -6
View File
@@ -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)
}
}
+113 -4
View File
@@ -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})
+4 -4
View File
@@ -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()
+1
View File
@@ -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"`
}
+5 -9
View File
@@ -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))
}
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+8 -3
View File
@@ -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",
+8 -3
View File
@@ -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ờ",
+8 -3
View File
@@ -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..."],
},