feat(channels): use generated quick ack progress

This commit is contained in:
Goon committed 2026-05-31 16:19:23 +07:00
1 parent afa32f5fa0
commit 32273f04d3
20 files changed
+431 -68

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))
}
@@ -1,9 +1,9 @@
---
phase: 1
title: "Characterization Tests"
status: pending
title: Characterization Tests
status: completed
priority: P1
effort: ""
effort: ''
dependencies: []
---
@@ -1,10 +1,11 @@
---
phase: 2
title: "Config Contract"
status: pending
title: Config Contract
status: completed
priority: P1
effort: ""
dependencies: [1]
effort: ''
dependencies:
- 1
---
# Phase 2: Config Contract
@@ -1,10 +1,12 @@
---
phase: 3
title: "Runtime Delivery"
status: pending
title: Runtime Delivery
status: completed
priority: P1
effort: ""
dependencies: [1, 2]
effort: ''
dependencies:
- 1
- 2
---
# Phase 3: Runtime Delivery
@@ -1,10 +1,12 @@
---
phase: 4
title: "Dashboard Controls"
status: pending
title: Dashboard Controls
status: completed
priority: P2
effort: ""
dependencies: [2, 3]
effort: ''
dependencies:
- 2
- 3
---
# Phase 4: Dashboard Controls
@@ -1,10 +1,14 @@
---
phase: 5
title: "Validation and Ship Handoff"
status: pending
title: Validation and Ship Handoff
status: completed
priority: P1
effort: ""
dependencies: [1, 2, 3, 4]
effort: ''
dependencies:
- 1
- 2
- 3
- 4
---
# Phase 5: Validation and Ship Handoff
@@ -1,14 +1,22 @@
---
title: "Issue 118 LLM-Generated Channel Progress"
description: "TDD plan for digitopvn/goclaw#118: make channel immediate/progress replies LLM-generated through the existing main-turn block.reply path, with fixed quick_ack templates retained only as fallback."
status: pending
title: Issue 118 LLM-Generated Channel Progress
description: >-
TDD plan for digitopvn/goclaw#118: make channel immediate/progress replies
LLM-generated through the existing main-turn block.reply path, with fixed
quick_ack templates retained only as fallback.
status: completed
priority: P2
branch: "codex/issue-118-llm-generated-progress-messages"
tags: [issue-118, channels, chat-behavior, block-reply, tdd]
branch: codex/issue-118-llm-generated-progress-messages
tags:
- issue-118
- channels
- chat-behavior
- block-reply
- tdd
blockedBy: []
blocks: []
created: "2026-05-31T08:55:50.410Z"
createdBy: "ck:plan"
created: '2026-05-31T08:55:50.410Z'
createdBy: 'ck:plan'
source: skill
---
@@ -52,11 +60,11 @@ Explicitly out of scope:
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Characterization Tests](./phase-01-characterization-tests.md) | Pending |
| 2 | [Config Contract](./phase-02-config-contract.md) | Pending |
| 3 | [Runtime Delivery](./phase-03-runtime-delivery.md) | Pending |
| 4 | [Dashboard Controls](./phase-04-dashboard-controls.md) | Pending |
| 5 | [Validation and Ship Handoff](./phase-05-validation-and-ship-handoff.md) | Pending |
| 1 | [Characterization Tests](./phase-01-characterization-tests.md) | Completed |
| 2 | [Config Contract](./phase-02-config-contract.md) | Completed |
| 3 | [Runtime Delivery](./phase-03-runtime-delivery.md) | Completed |
| 4 | [Dashboard Controls](./phase-04-dashboard-controls.md) | Completed |
| 5 | [Validation and Ship Handoff](./phase-05-validation-and-ship-handoff.md) | Completed |
## Dependencies
+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": "待处理消息压缩",
@@ -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..."],
},