Skip to content

Commit 681257e

Browse files
committed
feat: add 'enhance' interaction mode — narrated tool messages, persist after response
1 parent e4f44e3 commit 681257e

5 files changed

Lines changed: 146 additions & 17 deletions

File tree

cmd/odek/telegram.go

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,18 +1009,23 @@ func handleChatMessage(
10091009
}
10101010
}()
10111011

1012-
// ── Engaging Mode: Immediate Progress ──────────────────────────
1012+
// ── Progress Mode Setup ───────────────────────────────────────
10131013
// Send an instant "working on it" message so the user sees feedback
1014-
// within milliseconds, not seconds. This message gets updated with
1015-
// narrator-style progress on each tool call, then deleted when the
1016-
// final answer arrives.
1014+
// within milliseconds, not seconds.
1015+
//
1016+
// engaging: updated with narrated tool descriptions (edits), then
1017+
// deleted when the final answer arrives.
1018+
// enhance: kept as a per-iteration header; narrated tool messages
1019+
// are appended below it as new messages (no edits).
1020+
// verbose: no progress message — uses raw tool traces instead.
10171021
var narrator *narrate.Narrator
1018-
isEngaging := resolved.InteractionMode != "verbose"
1019-
if isEngaging {
1022+
isEngaging := resolved.InteractionMode == "engaging"
1023+
isEnhance := resolved.InteractionMode == "enhance"
1024+
if isEngaging || isEnhance {
10201025
narrator = narrate.New(true)
10211026
}
10221027
var progressMsgID int
1023-
if isEngaging {
1028+
if isEngaging || isEnhance {
10241029
msg, err := bot.SendMessage(chatID, "🤔 Looking into that...",
10251030
&telegram.SendOpts{ReplyToMessageID: messageID})
10261031
if err == nil {
@@ -1029,9 +1034,10 @@ func handleChatMessage(
10291034
}
10301035
defer func() {
10311036
// Clean up the progress message when the task finishes.
1032-
// The final answer replaces it entirely — this avoids cluttering
1033-
// the chat with a stale "thinking" message.
1034-
if progressMsgID != 0 {
1037+
// In engaging mode, deletes the stale "thinking" message.
1038+
// In enhance mode, preserves it as a per-iteration header
1039+
// with the narrated tool messages below it.
1040+
if progressMsgID != 0 && isEngaging {
10351041
bot.DeleteMessage(chatID, progressMsgID)
10361042
}
10371043
}()
@@ -1149,9 +1155,28 @@ func handleChatMessage(
11491155
Tools: agentTools,
11501156
Renderer: rend,
11511157
ToolEventHandler: func(event string, name string, data string) {
1158+
// Enhance mode: send new messages with narrated descriptions.
1159+
// Each tool call gets its own message (no edits, no cleanup).
1160+
if isEnhance {
1161+
switch event {
1162+
case "tool_call":
1163+
if narrator != nil {
1164+
if msg := narrator.ToolCallMessage(name, data); msg != "" {
1165+
escapedMsg := telegram.EscapeMarkdown(msg)
1166+
bot.SendMessage(chatID, escapedMsg,
1167+
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
1168+
}
1169+
}
1170+
case "tool_result":
1171+
// silent in enhance mode -- the narrated tool_call
1172+
// message already describes what's happening.
1173+
}
1174+
return
1175+
}
1176+
11521177
// Engaging mode: update the progress message with narrated tool
11531178
// descriptions instead of raw traces.
1154-
if resolved.InteractionMode != "verbose" {
1179+
if isEngaging {
11551180
if progressMsgID != 0 && event == "tool_call" && narrator != nil {
11561181
if msg := narrator.ToolCallMessage(name, data); msg != "" {
11571182
bot.EditMessageText(chatID, progressMsgID, msg, nil)

cmd/odek/telegram_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,107 @@ func TestModeCommand(t *testing.T) {
564564
}
565565
}
566566

567+
// TestEnhanceMode_SendsNarratedMessages tests that enhance mode sends
568+
// new narrated messages per tool_call (no edits, no cleanup, silent tool_result).
569+
func TestEnhanceMode_SendsNarratedMessages(t *testing.T) {
570+
bot := newMockBot()
571+
572+
chatID := int64(123)
573+
messageID := 1
574+
isEnhance := true
575+
576+
// Simulate the narrator for enhance mode
577+
type narrateMsg struct{}
578+
var progressMsgID int
579+
if isEnhance {
580+
msg, _ := bot.SendMessage(chatID, "🤔 Looking into that...",
581+
&telegram.SendOpts{ReplyToMessageID: messageID})
582+
if msg != nil {
583+
progressMsgID = msg.ID
584+
}
585+
}
586+
587+
// Simulate enhance-mode tool handler
588+
toolHandler := func(event string, name string, data string) {
589+
if !isEnhance {
590+
return
591+
}
592+
switch event {
593+
case "tool_call":
594+
msg := narratorToolCallMessage(name, data)
595+
if msg != "" {
596+
bot.SendMessage(chatID, msg,
597+
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
598+
}
599+
case "tool_result":
600+
// silent in enhance mode
601+
}
602+
}
603+
604+
// Fire events: one iteration with reasoning + 2 tool calls
605+
toolHandler("tool_call", "read_file", `{"path":"/etc/hostname"}`)
606+
toolHandler("tool_result", "read_file", `{"content":"my-host"}`)
607+
toolHandler("tool_call", "shell", `{"command":"go test ./..."}`)
608+
toolHandler("tool_result", "shell", `{"output":"PASS","exit_code":0}`)
609+
610+
calls := bot.recorded()
611+
t.Logf("Enhance mode sequence (%d calls):", len(calls))
612+
for i, c := range calls {
613+
t.Logf(" %d. %s %q (msgID=%d)", i+1, c.Method, truncateStr(c.Text, 60), c.MsgID)
614+
}
615+
616+
// Expected: 3 sendMessages (thinking node + 2 narrated tool_call)
617+
// No edits, no deletes
618+
if len(calls) != 3 {
619+
t.Fatalf("expected 3 calls (thinking node + 2 narrated), got %d", len(calls))
620+
}
621+
622+
// Call 1: thinking node
623+
if calls[0].Method != "sendMessage" || !strings.Contains(calls[0].Text, "🤔") {
624+
t.Errorf("call 1: expected sendMessage with thinking node, got %s %q", calls[0].Method, calls[0].Text)
625+
}
626+
627+
// Call 2: narrated read_file
628+
if calls[1].Method != "sendMessage" || !strings.Contains(calls[1].Text, "📖") {
629+
t.Errorf("call 2: expected sendMessage with narrated read_file, got %s %q", calls[1].Method, calls[1].Text)
630+
}
631+
632+
// Call 3: narrated shell
633+
if calls[2].Method != "sendMessage" || !strings.Contains(calls[2].Text, "⚙️") {
634+
t.Errorf("call 3: expected sendMessage with narrated shell, got %s %q", calls[2].Method, calls[2].Text)
635+
}
636+
637+
// No editMessageText
638+
for _, c := range calls {
639+
if c.Method == "editMessageText" {
640+
t.Errorf("unexpected editMessageText in enhance mode: %q", c.Text)
641+
}
642+
}
643+
644+
// No deleteMessage
645+
for _, c := range calls {
646+
if c.Method == "deleteMessage" {
647+
t.Errorf("unexpected deleteMessage in enhance mode (msgID=%d)", c.MsgID)
648+
}
649+
}
650+
651+
// Verify progressMsgID is NOT deleted (it was stored but defer cleanup
652+
// skipped it because isEngaging=false)
653+
_ = progressMsgID
654+
}
655+
656+
// narratorToolCallMessage replicates the narrator's ToolCallMessage for tests.
657+
func narratorToolCallMessage(name, args string) string {
658+
switch name {
659+
case "read_file":
660+
return "📖 Reading `hostname`..."
661+
case "shell":
662+
return "⚙️ Running `go test ./...`..."
663+
default:
664+
return "🔧 Working on `" + name + "`..."
665+
}
666+
}
667+
567668
// truncateStr truncates a string for display in test logs.
568669
func truncateStr(s string, n int) string {
569670
if len(s) <= n {

internal/config/loader.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ type CLIFlags struct {
7070
GithubRepoUrl string
7171

7272
// InteractionMode controls how tool-call progress is surfaced.
73-
// "engaging" (default) = LLM-narrated, emoji-rich explanations.
73+
// "engaging" (default) = emoji-rich narration, progress message edited.
74+
// "enhance" = per-tool narrated messages appended, progress header kept.
7475
// "verbose" = raw tool names, args, and results.
7576
InteractionMode string
7677
}
@@ -166,7 +167,8 @@ type FileConfig struct {
166167
GithubRepoUrl string `json:"github_repo_url,omitempty"`
167168

168169
// InteractionMode controls how the agent communicates tool/progress updates.
169-
// "engaging" (default) = LLM-narrated, emoji-rich explanations.
170+
// "engaging" (default) = emoji-rich narration, progress message edited.
171+
// "enhance" = per-tool narrated messages, progress header kept.
170172
// "verbose" = raw tool names, args, and results.
171173
InteractionMode string `json:"interaction_mode,omitempty"`
172174
}
@@ -261,7 +263,7 @@ type ResolvedConfig struct {
261263
GithubRepoUrl string
262264

263265
// InteractionMode is the resolved interaction style.
264-
// "engaging" (default) or "verbose".
266+
// "engaging" (default), "enhance", or "verbose".
265267
InteractionMode string
266268
}
267269

internal/telegram/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func modeHandler(args string) (string, error) {
137137
return "⚙️ *Agent Modes*\n\n" +
138138
"Modes are set at startup via `odek.json` or CLI flags:\n" +
139139
"• `interaction_mode: engaging` — emoji-rich narration (default)\n" +
140+
"• `interaction_mode: enhance` — narrated tool summaries (persist)\n" +
140141
"• `interaction_mode: verbose` — raw tool call output\n" +
141142
"• `sandbox: true` — run in Docker isolation\n" +
142143
"• `skills.verbose: true` — show skill learning details\n\n" +

odek.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ type Config struct {
114114
// after each tool invocation. Used by the WebUI for live streaming.
115115
ToolEventHandler func(event string, name string, data string)
116116

117-
// InteractionMode controls tool-call rendering: "engaging" (default) or "verbose".
117+
// InteractionMode controls tool-call rendering: "engaging" (default), "enhance", or "verbose".
118118
InteractionMode string
119119

120120
// IterationCallback, if set, is invoked after each iteration of the
@@ -566,9 +566,9 @@ func New(cfg Config) (*Agent, error) {
566566
engine.SetIterationCallback(cfg.IterationCallback)
567567
}
568568

569-
// Wire narrator for engaging interaction mode (default).
569+
// Wire narrator for engaging/enhance interaction modes.
570570
// In verbose mode, narrator stays nil → existing renderer behavior.
571-
if cfg.InteractionMode == "" || cfg.InteractionMode == "engaging" {
571+
if cfg.InteractionMode == "" || cfg.InteractionMode == "engaging" || cfg.InteractionMode == "enhance" {
572572
engine.SetNarrator(narrate.New(true))
573573
}
574574

0 commit comments

Comments
 (0)