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 index 8ee15ecf..0b7c6fa1 100644 --- 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 @@ -1,9 +1,9 @@ --- phase: 1 -title: "Characterization Tests" -status: pending +title: Characterization Tests +status: completed priority: P1 -effort: "" +effort: '' dependencies: [] --- 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 index 8eb231c1..c773ceea 100644 --- 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 @@ -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 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 index 4dd0b6dc..b2b50109 100644 --- 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 @@ -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 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 index a519c558..b4c4bfc4 100644 --- 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 @@ -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 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 index b9b83d9c..6a3b4140 100644 --- 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 @@ -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 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 index aa2f8b7d..0eec3d69 100644 --- a/plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md +++ b/plans/260531-1555-issue-118-llm-generated-channel-progress/plan.md @@ -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 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-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..."], },