Skip to content

Commit 53afae0

Browse files
committed
feat(telegram): complete narrator integration with progress messages
- Wire narrator into handleChatMessage for engaging mode - Send instant '🤔 Looking into that...' progress message - Update progress message on each tool_call with narrated descriptions - Delete progress message when answer arrives (clean chat) - Add InteractionMode config tests (default, env, CLI override) - Add /mode command test verifying interaction_mode documentation
1 parent e850404 commit 53afae0

3 files changed

Lines changed: 110 additions & 3 deletions

File tree

cmd/odek/telegram.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/BackendStack21/kode/internal/llm"
2222
"github.com/BackendStack21/kode/internal/loop"
2323
"github.com/BackendStack21/kode/internal/render"
24+
"github.com/BackendStack21/kode/internal/narrate"
2425
"github.com/BackendStack21/kode/internal/session"
2526
"github.com/BackendStack21/kode/internal/skills"
2627
"github.com/BackendStack21/kode/internal/telegram"
@@ -987,11 +988,38 @@ func handleChatMessage(
987988
}
988989
}
989990
}()
991+
992+
// ── Engaging Mode: Immediate Progress ──────────────────────────
993+
// Send an instant "working on it" message so the user sees feedback
994+
// within milliseconds, not seconds. This message gets updated with
995+
// narrator-style progress on each tool call, then deleted when the
996+
// final answer arrives.
997+
var narrator *narrate.Narrator
998+
isEngaging := resolved.InteractionMode != "verbose"
999+
if isEngaging {
1000+
narrator = narrate.New(true)
1001+
}
1002+
var progressMsgID int
1003+
if isEngaging {
1004+
msg, err := bot.SendMessage(chatID, "🤔 Looking into that...",
1005+
&telegram.SendOpts{ReplyToMessageID: messageID})
1006+
if err == nil {
1007+
progressMsgID = msg.ID
1008+
}
1009+
}
1010+
defer func() {
1011+
// Clean up the progress message when the task finishes.
1012+
// The final answer replaces it entirely — this avoids cluttering
1013+
// the chat with a stale "thinking" message.
1014+
if progressMsgID != 0 {
1015+
bot.DeleteMessage(chatID, progressMsgID)
1016+
}
1017+
}()
9901018

9911019
// ── Tool Tracing ───────────────────────────────────────────────
9921020
// Single editable message showing live tool execution progress.
993-
// The message is created lazily — only when the first tool call
994-
// fires, not before. This avoids the premature "🤔 Thinking…" spam.
1021+
// In verbose mode this shows raw tool names/results; in engaging
1022+
// mode it's replaced by the narrator progress message above.
9951023
var traceMsgID int
9961024
var traceMu sync.Mutex
9971025
traceLines := make([]string, 0, 8)
@@ -1064,8 +1092,16 @@ func handleChatMessage(
10641092
Tools: agentTools,
10651093
Renderer: rend,
10661094
ToolEventHandler: func(event string, name string, data string) {
1067-
// In engaging mode, skip the raw tool trace — narrator handles it.
1095+
// Engaging mode: update the progress message with narrated tool
1096+
// descriptions instead of raw traces. We skip tool_result events
1097+
// here — the next tool_call will overwrite the message anyway,
1098+
// keeping the chat scannable and avoiding flash edits.
10681099
if resolved.InteractionMode != "verbose" {
1100+
if progressMsgID != 0 && event == "tool_call" && narrator != nil {
1101+
if msg := narrator.ToolCallMessage(name, data); msg != "" {
1102+
bot.EditMessageText(chatID, progressMsgID, msg, nil)
1103+
}
1104+
}
10691105
return
10701106
}
10711107
traceMu.Lock()

cmd/odek/telegram_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,3 +773,43 @@ func newTestHandler(bot *telegram.Bot) *telegram.Handler {
773773
h.Config = telegram.HandlerConfig{}
774774
return h
775775
}
776+
777+
// ── /mode command tests ──────────────────────────────────────────────
778+
779+
func TestOnCommandMode_ShowsInteractionMode(t *testing.T) {
780+
// /mode must document interaction_mode (engaging and verbose).
781+
chatID := int64(99920)
782+
783+
bot := newTestBot(t)
784+
h := newTestHandler(bot)
785+
786+
h.OnCommand = func(cid int64, mid int, cmdName string, argsStr string) (string, error) {
787+
if cmdName == "mode" {
788+
return "⚙️ *Agent Modes*\n\n" +
789+
"Modes are set at startup via `odek.json` or CLI flags:\n" +
790+
"• `interaction_mode: engaging` — emoji-rich narration (default)\n" +
791+
"• `interaction_mode: verbose` — raw tool call output\n" +
792+
"• `sandbox: true` — run in Docker isolation\n" +
793+
"• `skills.verbose: true` — show skill learning details\n\n" +
794+
"Restart the bot after changing config.", nil
795+
}
796+
return "", nil
797+
}
798+
799+
result, err := h.OnCommand(chatID, 0, "mode", "")
800+
if err != nil {
801+
t.Fatalf("OnCommand /mode returned error: %v", err)
802+
}
803+
804+
checks := []string{
805+
"interaction_mode",
806+
"engaging",
807+
"verbose",
808+
"Agent Modes",
809+
}
810+
for _, c := range checks {
811+
if !strings.Contains(result, c) {
812+
t.Errorf("expected /mode output to contain %q, got: %q", c, result)
813+
}
814+
}
815+
}

internal/config/loader_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,3 +717,34 @@ func TestLoadConfig_ClearsAPIKeyFromEnviron(t *testing.T) {
717717
t.Errorf("OPENAI_API_KEY should be cleared after LoadConfig, got %q", v)
718718
}
719719
}
720+
721+
func TestLoadConfig_InteractionModeDefaults(t *testing.T) {
722+
// When no interaction_mode is configured, the resolved config must
723+
// default to "engaging".
724+
cfg := LoadConfig(CLIFlags{})
725+
if cfg.InteractionMode != "engaging" {
726+
t.Errorf("InteractionMode = %q, want %q", cfg.InteractionMode, "engaging")
727+
}
728+
}
729+
730+
func TestLoadConfig_InteractionModeViaEnv(t *testing.T) {
731+
// ODEK_INTERACTION_MODE should override the default.
732+
os.Setenv("ODEK_INTERACTION_MODE", "verbose")
733+
defer os.Unsetenv("ODEK_INTERACTION_MODE")
734+
735+
cfg := LoadConfig(CLIFlags{})
736+
if cfg.InteractionMode != "verbose" {
737+
t.Errorf("InteractionMode = %q, want %q", cfg.InteractionMode, "verbose")
738+
}
739+
}
740+
741+
func TestLoadConfig_InteractionModeViaCLI(t *testing.T) {
742+
// CLI flag should take precedence over env.
743+
os.Setenv("ODEK_INTERACTION_MODE", "engaging")
744+
defer os.Unsetenv("ODEK_INTERACTION_MODE")
745+
746+
cfg := LoadConfig(CLIFlags{InteractionMode: "verbose"})
747+
if cfg.InteractionMode != "verbose" {
748+
t.Errorf("InteractionMode = %q, want %q", cfg.InteractionMode, "verbose")
749+
}
750+
}

0 commit comments

Comments
 (0)