diff --git a/cmd/agent/app.go b/cmd/agent/app.go index 5ca3c08d..7b0ba346 100644 --- a/cmd/agent/app.go +++ b/cmd/agent/app.go @@ -374,6 +374,7 @@ func provideChannelRouter( processor.SetDispatcher(inbound.NewRouteDispatcher(log)) processor.SetSpeechService(audioService, &settingsSpeechModelResolver{settings: settingsService}) processor.SetTranscriptionService(&settingsTranscriptionAdapter{audio: audioService}, &settingsTranscriptionModelResolver{settings: settingsService}) + processor.SetIMDisplayOptions(&settingsIMDisplayOptions{settings: settingsService}) cmdHandler := command.NewHandler( log, &command.BotMemberRoleAdapter{BotService: botService}, @@ -597,6 +598,18 @@ func (r *settingsSpeechModelResolver) ResolveSpeechModelID(ctx context.Context, return s.TtsModelID, nil } +type settingsIMDisplayOptions struct { + settings *settings.Service +} + +func (r *settingsIMDisplayOptions) ShowToolCallsInIM(ctx context.Context, botID string) (bool, error) { + s, err := r.settings.GetBot(ctx, botID) + if err != nil { + return false, err + } + return s.ShowToolCallsInIM, nil +} + type settingsTranscriptionModelResolver struct { settings *settings.Service } diff --git a/db/migrations/0001_init.up.sql b/db/migrations/0001_init.up.sql index 855b6734..a9910510 100644 --- a/db/migrations/0001_init.up.sql +++ b/db/migrations/0001_init.up.sql @@ -179,6 +179,7 @@ CREATE TABLE IF NOT EXISTS bots ( transcription_model_id UUID REFERENCES models(id) ON DELETE SET NULL, browser_context_id UUID REFERENCES browser_contexts(id) ON DELETE SET NULL, persist_full_tool_results BOOLEAN NOT NULL DEFAULT false, + show_tool_calls_in_im BOOLEAN NOT NULL DEFAULT false, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), diff --git a/db/migrations/0072_add_show_tool_calls_in_im.down.sql b/db/migrations/0072_add_show_tool_calls_in_im.down.sql new file mode 100644 index 00000000..05ebbd60 --- /dev/null +++ b/db/migrations/0072_add_show_tool_calls_in_im.down.sql @@ -0,0 +1,5 @@ +-- 0072_add_show_tool_calls_in_im (down) +-- NOTE: After rolling back this migration, re-run `sqlc generate` to update the +-- generated Go code in internal/db/sqlc/. + +ALTER TABLE bots DROP COLUMN IF EXISTS show_tool_calls_in_im; diff --git a/db/migrations/0072_add_show_tool_calls_in_im.up.sql b/db/migrations/0072_add_show_tool_calls_in_im.up.sql new file mode 100644 index 00000000..87a096cb --- /dev/null +++ b/db/migrations/0072_add_show_tool_calls_in_im.up.sql @@ -0,0 +1,5 @@ +-- 0072_add_show_tool_calls_in_im +-- Add show_tool_calls_in_im column to bots table to control whether tool call +-- status messages are surfaced in IM channels. + +ALTER TABLE bots ADD COLUMN IF NOT EXISTS show_tool_calls_in_im BOOLEAN NOT NULL DEFAULT false; diff --git a/db/queries/settings.sql b/db/queries/settings.sql index 53ca739d..e97eb4b1 100644 --- a/db/queries/settings.sql +++ b/db/queries/settings.sql @@ -21,7 +21,8 @@ SELECT tts_models.id AS tts_model_id, transcription_models.id AS transcription_model_id, browser_contexts.id AS browser_context_id, - bots.persist_full_tool_results + bots.persist_full_tool_results, + bots.show_tool_calls_in_im FROM bots LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = bots.heartbeat_model_id @@ -59,9 +60,10 @@ WITH updated AS ( transcription_model_id = COALESCE(sqlc.narg(transcription_model_id)::uuid, bots.transcription_model_id), browser_context_id = COALESCE(sqlc.narg(browser_context_id)::uuid, bots.browser_context_id), persist_full_tool_results = sqlc.arg(persist_full_tool_results), + show_tool_calls_in_im = sqlc.arg(show_tool_calls_in_im), updated_at = now() WHERE bots.id = sqlc.arg(id) - RETURNING bots.id, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.compaction_ratio, bots.timezone, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.image_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.transcription_model_id, bots.browser_context_id, bots.persist_full_tool_results + RETURNING bots.id, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.compaction_ratio, bots.timezone, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.image_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.transcription_model_id, bots.browser_context_id, bots.persist_full_tool_results, bots.show_tool_calls_in_im ) SELECT updated.id AS bot_id, @@ -85,7 +87,8 @@ SELECT tts_models.id AS tts_model_id, transcription_models.id AS transcription_model_id, browser_contexts.id AS browser_context_id, - updated.persist_full_tool_results + updated.persist_full_tool_results, + updated.show_tool_calls_in_im FROM updated LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = updated.heartbeat_model_id @@ -120,5 +123,6 @@ SET language = 'auto', transcription_model_id = NULL, browser_context_id = NULL, persist_full_tool_results = false, + show_tool_calls_in_im = false, updated_at = now() WHERE id = $1; diff --git a/internal/agent/agent.go b/internal/agent/agent.go index de78c696..9409eb0e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -300,16 +300,14 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv } case *sdk.ToolInputStartPart: + // ToolInputStartPart fires before tool input args have streamed. + // We suppress it here because downstream consumers (IM adapters and + // Web UI) only care about the fully-assembled call announced by + // StreamToolCallPart below. Emitting a start event twice for the + // same CallID would produce duplicate "running" messages in IMs. if textLoopProbeBuffer != nil { textLoopProbeBuffer.Flush() } - if !sendEvent(ctx, ch, StreamEvent{ - Type: EventToolCallStart, - ToolName: p.ToolName, - ToolCallID: p.ID, - }) { - aborted = true - } case *sdk.StreamToolCallPart: if textLoopProbeBuffer != nil { @@ -981,16 +979,12 @@ func (a *Agent) runMidStreamRetry( aborted = true } case *sdk.ToolInputStartPart: + // See ToolInputStartPart note above: suppress the early start + // and rely on StreamToolCallPart (which carries the fully + // assembled Input) as the single source of truth. if textLoopProbeBuffer != nil { textLoopProbeBuffer.Flush() } - if !sendEvent(sendCtx, ch, StreamEvent{ - Type: EventToolCallStart, - ToolName: rp.ToolName, - ToolCallID: rp.ID, - }) { - aborted = true - } case *sdk.StreamToolCallPart: if textLoopProbeBuffer != nil { textLoopProbeBuffer.Flush() diff --git a/internal/agent/stream_test.go b/internal/agent/stream_test.go index acbef0fb..9c1ad0b3 100644 --- a/internal/agent/stream_test.go +++ b/internal/agent/stream_test.go @@ -46,7 +46,12 @@ func (*agentToolPlaceholderProvider) DoStream(_ context.Context, _ sdk.GenerateP return &sdk.StreamResult{Stream: ch}, nil } -func TestAgentStreamEmitsEarlyToolPlaceholderBeforeFullInput(t *testing.T) { +// TestAgentStreamEmitsToolCallStartOnceWithInput asserts that each tool call +// produces exactly one EventToolCallStart with the fully-assembled Input, even +// though the underlying SDK emits a preliminary ToolInputStartPart (no input) +// followed by a StreamToolCallPart (with input). Emitting two start events per +// call caused duplicate "running" messages in IM adapters. +func TestAgentStreamEmitsToolCallStartOnceWithInput(t *testing.T) { t.Parallel() a := New(Deps{}) @@ -64,26 +69,20 @@ func TestAgentStreamEmitsEarlyToolPlaceholderBeforeFullInput(t *testing.T) { events = append(events, event) } - if len(events) != 4 { - t.Fatalf("expected 4 events, got %d: %#v", len(events), events) + if len(events) != 3 { + t.Fatalf("expected 3 events, got %d: %#v", len(events), events) } if events[0].Type != EventAgentStart { t.Fatalf("expected first event %q, got %#v", EventAgentStart, events[0]) } if events[1].Type != EventToolCallStart || events[1].ToolCallID != "call-1" || events[1].ToolName != "write" { - t.Fatalf("unexpected placeholder tool event: %#v", events[1]) - } - if events[1].Input != nil { - t.Fatalf("expected placeholder tool event to have nil input, got %#v", events[1].Input) - } - if events[2].Type != EventToolCallStart || events[2].ToolCallID != "call-1" { - t.Fatalf("unexpected full tool event: %#v", events[2]) + t.Fatalf("unexpected tool call start event: %#v", events[1]) } expectedInput := map[string]any{"path": "/tmp/long.txt"} - if !reflect.DeepEqual(events[2].Input, expectedInput) { - t.Fatalf("expected full tool event input %#v, got %#v", expectedInput, events[2].Input) + if !reflect.DeepEqual(events[1].Input, expectedInput) { + t.Fatalf("expected tool call start input %#v, got %#v", expectedInput, events[1].Input) } - if events[3].Type != EventAgentEnd { - t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[3]) + if events[2].Type != EventAgentEnd { + t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[2]) } } diff --git a/internal/channel/adapters/dingtalk/stream.go b/internal/channel/adapters/dingtalk/stream.go index 17a524a6..8fc89847 100644 --- a/internal/channel/adapters/dingtalk/stream.go +++ b/internal/channel/adapters/dingtalk/stream.go @@ -45,7 +45,6 @@ func (s *dingtalkOutboundStream) Push(ctx context.Context, event channel.Prepare channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventToolCallStart, - channel.StreamEventToolCallEnd, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventProcessingStarted, @@ -54,6 +53,18 @@ func (s *dingtalkOutboundStream) Push(ctx context.Context, event channel.Prepare // Non-content events: no-op. return nil + case channel.StreamEventToolCallEnd: + text := strings.TrimSpace(channel.RenderToolCallMessage(channel.BuildToolCallEnd(event.ToolCall))) + if text == "" { + return nil + } + return s.adapter.Send(ctx, s.cfg, channel.PreparedOutboundMessage{ + Target: s.target, + Message: channel.PreparedMessage{ + Message: channel.Message{Format: channel.MessageFormatPlain, Text: text, Reply: s.reply}, + }, + }) + case channel.StreamEventDelta: if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning { return nil diff --git a/internal/channel/adapters/discord/stream.go b/internal/channel/adapters/discord/stream.go index 891d5853..83acdbe5 100644 --- a/internal/channel/adapters/discord/stream.go +++ b/internal/channel/adapters/discord/stream.go @@ -15,16 +15,17 @@ import ( ) type discordOutboundStream struct { - adapter *DiscordAdapter - cfg channel.ChannelConfig - target string - reply *channel.ReplyRef - session *discordgo.Session - closed atomic.Bool - mu sync.Mutex - msgID string - buffer strings.Builder - lastUpdate time.Time + adapter *DiscordAdapter + cfg channel.ChannelConfig + target string + reply *channel.ReplyRef + session *discordgo.Session + closed atomic.Bool + mu sync.Mutex + msgID string + buffer strings.Builder + lastUpdate time.Time + toolMessages map[string]string } func (s *discordOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error { @@ -103,7 +104,21 @@ func (s *discordOutboundStream) Push(ctx context.Context, event channel.Prepared } return nil - case channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted, channel.StreamEventProcessingFailed, channel.StreamEventToolCallStart, channel.StreamEventToolCallEnd: + case channel.StreamEventToolCallStart: + s.mu.Lock() + bufText := strings.TrimSpace(s.buffer.String()) + s.mu.Unlock() + if bufText != "" { + if err := s.finalizeMessage(bufText); err != nil { + return err + } + } + s.resetStreamState() + return s.sendToolCallMessage(event.ToolCall, channel.BuildToolCallStart(event.ToolCall)) + case channel.StreamEventToolCallEnd: + return s.sendToolCallMessage(event.ToolCall, channel.BuildToolCallEnd(event.ToolCall)) + + case channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted, channel.StreamEventProcessingFailed: // Status events - no action needed for Discord return nil @@ -207,6 +222,82 @@ func (s *discordOutboundStream) finalizeMessage(text string) error { return err } +// sendToolCallMessage posts a Discord message on tool_call_start and edits it +// on tool_call_end so the running → completed/failed transition is contained +// in one visible post. Falls back to a new message if the edit fails. +func (s *discordOutboundStream) sendToolCallMessage(tc *channel.StreamToolCall, p channel.ToolCallPresentation) error { + text := truncateDiscordText(strings.TrimSpace(channel.RenderToolCallMessageMarkdown(p))) + if text == "" { + return nil + } + callID := "" + if tc != nil { + callID = strings.TrimSpace(tc.CallID) + } + if p.Status != channel.ToolCallStatusRunning && callID != "" { + if msgID, ok := s.lookupToolCallMessage(callID); ok { + if _, err := s.session.ChannelMessageEdit(s.target, msgID, text); err == nil { + s.forgetToolCallMessage(callID) + return nil + } + s.forgetToolCallMessage(callID) + } + } + var msg *discordgo.Message + var err error + if s.reply != nil && s.reply.MessageID != "" { + msg, err = s.session.ChannelMessageSendReply(s.target, text, &discordgo.MessageReference{ + ChannelID: s.target, + MessageID: s.reply.MessageID, + }) + } else { + msg, err = s.session.ChannelMessageSend(s.target, text) + } + if err != nil { + return err + } + if p.Status == channel.ToolCallStatusRunning && callID != "" && msg != nil && msg.ID != "" { + s.storeToolCallMessage(callID, msg.ID) + } + return nil +} + +func (s *discordOutboundStream) lookupToolCallMessage(callID string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return "", false + } + v, ok := s.toolMessages[callID] + return v, ok +} + +func (s *discordOutboundStream) storeToolCallMessage(callID, msgID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + s.toolMessages = make(map[string]string) + } + s.toolMessages[callID] = msgID +} + +func (s *discordOutboundStream) forgetToolCallMessage(callID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return + } + delete(s.toolMessages, callID) +} + +func (s *discordOutboundStream) resetStreamState() { + s.mu.Lock() + s.msgID = "" + s.buffer.Reset() + s.lastUpdate = time.Time{} + s.mu.Unlock() +} + func (s *discordOutboundStream) sendAttachment(ctx context.Context, att channel.PreparedAttachment) error { file, err := discordPreparedAttachmentToFile(ctx, att) if err != nil { diff --git a/internal/channel/adapters/feishu/stream.go b/internal/channel/adapters/feishu/stream.go index b399f761..cfe7fab0 100644 --- a/internal/channel/adapters/feishu/stream.go +++ b/internal/channel/adapters/feishu/stream.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "regexp" "strings" "sync/atomic" @@ -38,6 +39,7 @@ type feishuOutboundStream struct { lastPatched string patchInterval time.Duration closed atomic.Bool + toolMessages map[string]string } func (s *feishuOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error { @@ -79,13 +81,13 @@ func (s *feishuOutboundStream) Push(ctx context.Context, event channel.PreparedS s.lastPatched = "" s.lastPatchedAt = time.Time{} s.textBuffer.Reset() - return nil + return s.renderToolCallCard(ctx, event.ToolCall, channel.BuildToolCallStart(event.ToolCall)) case channel.StreamEventToolCallEnd: s.cardMessageID = "" s.lastPatched = "" s.lastPatchedAt = time.Time{} s.textBuffer.Reset() - return nil + return s.renderToolCallCard(ctx, event.ToolCall, channel.BuildToolCallEnd(event.ToolCall)) case channel.StreamEventAttachment: if len(event.Attachments) == 0 { return nil @@ -367,6 +369,155 @@ func processFeishuCardMarkdown(s string) string { return s } +// renderToolCallCard posts a card for tool_call_start and patches the same +// card on tool_call_end, producing a single message whose status flips from +// "running" to "completed"/"failed". If no prior card is tracked (or patching +// fails), it falls back to creating a new card. +func (s *feishuOutboundStream) renderToolCallCard( + ctx context.Context, + tc *channel.StreamToolCall, + p channel.ToolCallPresentation, +) error { + text := strings.TrimSpace(channel.RenderToolCallMessageMarkdown(p)) + if text == "" { + return nil + } + if s.client == nil { + return errors.New("feishu client not configured") + } + callID := "" + if tc != nil { + callID = strings.TrimSpace(tc.CallID) + } + if p.Status != channel.ToolCallStatusRunning && callID != "" { + if existing, ok := s.lookupToolCallMessage(callID); ok { + patchErr := s.patchToolCallCard(ctx, existing, text) + if patchErr == nil { + s.forgetToolCallMessage(callID) + return nil + } + if s.adapter != nil && s.adapter.logger != nil { + s.adapter.logger.Warn("feishu: tool-call end patch failed, falling back to new card", + slog.String("call_id", callID), + slog.Any("error", patchErr), + ) + } + s.forgetToolCallMessage(callID) + } + } + msgID, err := s.sendToolCallCard(ctx, text) + if err != nil { + return err + } + if p.Status == channel.ToolCallStatusRunning && callID != "" && msgID != "" { + s.storeToolCallMessage(callID, msgID) + } + return nil +} + +func (s *feishuOutboundStream) patchToolCallCard(ctx context.Context, messageID, text string) error { + content, err := buildFeishuCardContent(text) + if err != nil { + return err + } + patchReq := larkim.NewPatchMessageReqBuilder(). + MessageId(messageID). + Body(larkim.NewPatchMessageReqBodyBuilder(). + Content(content). + Build()). + Build() + patchResp, err := s.client.Im.Message.Patch(ctx, patchReq) + if err != nil { + return err + } + if patchResp == nil || !patchResp.Success() { + code, msg := 0, "" + if patchResp != nil { + code, msg = patchResp.Code, patchResp.Msg + } + return fmt.Errorf("feishu tool card patch failed: %s (code: %d)", msg, code) + } + return nil +} + +func (s *feishuOutboundStream) lookupToolCallMessage(callID string) (string, bool) { + if s.toolMessages == nil { + return "", false + } + m, ok := s.toolMessages[callID] + return m, ok +} + +func (s *feishuOutboundStream) storeToolCallMessage(callID, messageID string) { + if s.toolMessages == nil { + s.toolMessages = make(map[string]string) + } + s.toolMessages[callID] = messageID +} + +func (s *feishuOutboundStream) forgetToolCallMessage(callID string) { + if s.toolMessages == nil { + return + } + delete(s.toolMessages, callID) +} + +func (s *feishuOutboundStream) sendToolCallCard(ctx context.Context, text string) (string, error) { + content, err := buildFeishuCardContent(text) + if err != nil { + return "", err + } + if s.reply != nil && strings.TrimSpace(s.reply.MessageID) != "" { + replyReq := larkim.NewReplyMessageReqBuilder(). + MessageId(strings.TrimSpace(s.reply.MessageID)). + Body(larkim.NewReplyMessageReqBodyBuilder(). + Content(content). + MsgType(larkim.MsgTypeInteractive). + Uuid(uuid.NewString()). + Build()). + Build() + replyResp, err := s.client.Im.Message.Reply(ctx, replyReq) + if err != nil { + return "", err + } + if replyResp == nil || !replyResp.Success() { + code, msg := 0, "" + if replyResp != nil { + code, msg = replyResp.Code, replyResp.Msg + } + return "", fmt.Errorf("feishu tool card reply failed: %s (code: %d)", msg, code) + } + if replyResp.Data == nil || replyResp.Data.MessageId == nil { + return "", nil + } + return strings.TrimSpace(*replyResp.Data.MessageId), nil + } + createReq := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(s.receiveType). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(s.receiveID). + MsgType(larkim.MsgTypeInteractive). + Content(content). + Uuid(uuid.NewString()). + Build()). + Build() + createResp, err := s.client.Im.Message.Create(ctx, createReq) + if err != nil { + return "", err + } + if createResp == nil || !createResp.Success() { + code, msg := 0, "" + if createResp != nil { + code, msg = createResp.Code, createResp.Msg + } + return "", fmt.Errorf("feishu tool card create failed: %s (code: %d)", msg, code) + } + if createResp.Data == nil || createResp.Data.MessageId == nil { + return "", nil + } + return strings.TrimSpace(*createResp.Data.MessageId), nil +} + func normalizeFeishuStreamText(text string) string { trimmed := strings.TrimSpace(text) if trimmed == "" { diff --git a/internal/channel/adapters/matrix/stream.go b/internal/channel/adapters/matrix/stream.go index f4c925d6..2716c436 100644 --- a/internal/channel/adapters/matrix/stream.go +++ b/internal/channel/adapters/matrix/stream.go @@ -26,6 +26,7 @@ type matrixOutboundStream struct { lastText string lastFormat channel.MessageFormat lastEditedAt time.Time + toolMessages map[string]string } func (s *matrixOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error { @@ -44,7 +45,6 @@ func (s *matrixOutboundStream) Push(ctx context.Context, event channel.PreparedS switch event.Type { case channel.StreamEventStatus, channel.StreamEventPhaseStart, - channel.StreamEventToolCallEnd, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventProcessingStarted, @@ -60,8 +60,18 @@ func (s *matrixOutboundStream) Push(ctx context.Context, event channel.PreparedS s.mu.Unlock() return s.upsertText(ctx, text, channel.MessageFormatPlain, true) case channel.StreamEventToolCallStart: + s.mu.Lock() + bufText := strings.TrimSpace(s.rawBuffer.String()) + s.mu.Unlock() + if bufText != "" { + if err := s.upsertText(ctx, bufText, channel.MessageFormatPlain, true); err != nil { + return err + } + } s.resetMessageState() - return nil + return s.sendToolCallMessage(ctx, event.ToolCall, channel.BuildToolCallStart(event.ToolCall)) + case channel.StreamEventToolCallEnd: + return s.sendToolCallMessage(ctx, event.ToolCall, channel.BuildToolCallEnd(event.ToolCall)) case channel.StreamEventDelta: if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" { return nil @@ -186,6 +196,98 @@ func (s *matrixOutboundStream) upsertText(ctx context.Context, text string, form return nil } +// sendToolCallMessage posts a room event on tool_call_start and sends an +// m.replace edit event on tool_call_end so both lifecycle states share a +// single visible message. If no prior event is tracked (or the edit fails), +// it falls back to creating a new event. +func (s *matrixOutboundStream) sendToolCallMessage( + ctx context.Context, + tc *channel.StreamToolCall, + p channel.ToolCallPresentation, +) error { + text := strings.TrimSpace(channel.RenderToolCallMessageMarkdown(p)) + format := channel.MessageFormatMarkdown + if text == "" { + text = strings.TrimSpace(channel.RenderToolCallMessage(p)) + format = channel.MessageFormatPlain + } + if text == "" { + return nil + } + + s.mu.Lock() + roomID := s.roomID + reply := s.reply + s.mu.Unlock() + + if roomID == "" { + resolved, err := s.adapter.resolveRoomTarget(ctx, s.cfg, s.target) + if err != nil { + return err + } + roomID = resolved + s.mu.Lock() + s.roomID = roomID + s.mu.Unlock() + } + + callID := "" + if tc != nil { + callID = strings.TrimSpace(tc.CallID) + } + if p.Status != channel.ToolCallStatusRunning && callID != "" { + if eventID, ok := s.lookupToolCallMessage(callID); ok { + editMsg := channel.Message{Text: text, Format: format} + if _, err := s.adapter.sendTextEvent(ctx, s.cfg, roomID, buildMatrixMessageContent(editMsg, true, eventID)); err == nil { + s.forgetToolCallMessage(callID) + return nil + } + s.forgetToolCallMessage(callID) + } + } + msg := channel.Message{ + Text: text, + Format: format, + Reply: reply, + } + eventID, err := s.adapter.sendTextEvent(ctx, s.cfg, roomID, buildMatrixMessageContent(msg, false, "")) + if err != nil { + return err + } + if p.Status == channel.ToolCallStatusRunning && callID != "" && eventID != "" { + s.storeToolCallMessage(callID, eventID) + } + return nil +} + +func (s *matrixOutboundStream) lookupToolCallMessage(callID string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return "", false + } + v, ok := s.toolMessages[callID] + return v, ok +} + +func (s *matrixOutboundStream) storeToolCallMessage(callID, eventID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + s.toolMessages = make(map[string]string) + } + s.toolMessages[callID] = eventID +} + +func (s *matrixOutboundStream) forgetToolCallMessage(callID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return + } + delete(s.toolMessages, callID) +} + func (s *matrixOutboundStream) resetMessageState() { s.mu.Lock() s.originalEventID = "" diff --git a/internal/channel/adapters/matrix/stream_test.go b/internal/channel/adapters/matrix/stream_test.go index 9d81c481..80aad8bf 100644 --- a/internal/channel/adapters/matrix/stream_test.go +++ b/internal/channel/adapters/matrix/stream_test.go @@ -64,7 +64,7 @@ func TestMatrixStreamDoesNotSendDeltaBeforeTextPhaseEnds(t *testing.T) { } } -func TestMatrixStreamDropsBufferedTextWhenToolStarts(t *testing.T) { +func TestMatrixStreamFlushesBufferedTextWhenToolStarts(t *testing.T) { requests := 0 adapter := NewMatrixAdapter(nil) adapter.httpClient = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { @@ -89,11 +89,19 @@ func TestMatrixStreamDropsBufferedTextWhenToolStarts(t *testing.T) { if err := stream.Push(ctx, mustPreparedMatrixEvent(t, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "I will inspect first", Phase: channel.StreamPhaseText})); err != nil { t.Fatalf("push delta: %v", err) } - if err := stream.Push(ctx, mustPreparedMatrixEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallStart})); err != nil { + tcStart := &channel.StreamToolCall{Name: "read", CallID: "c1", Input: map[string]any{"path": "/tmp/a"}} + tcEnd := &channel.StreamToolCall{Name: "read", CallID: "c1", Input: map[string]any{"path": "/tmp/a"}, Result: map[string]any{"ok": true}} + if err := stream.Push(ctx, mustPreparedMatrixEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallStart, ToolCall: tcStart})); err != nil { t.Fatalf("push tool call start: %v", err) } - if requests != 0 { - t.Fatalf("expected no request for discarded pre-tool text, got %d", requests) + if requests != 2 { + t.Fatalf("expected flush + start messages, got %d", requests) + } + if err := stream.Push(ctx, mustPreparedMatrixEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallEnd, ToolCall: tcEnd})); err != nil { + t.Fatalf("push tool call end: %v", err) + } + if requests != 3 { + t.Fatalf("expected start + end tool messages plus flush, got %d", requests) } if err := stream.Push(ctx, mustPreparedMatrixEvent(t, channel.StreamEvent{Type: channel.StreamEventDelta, Delta: "Final answer", Phase: channel.StreamPhaseText})); err != nil { t.Fatalf("push final delta: %v", err) @@ -101,8 +109,8 @@ func TestMatrixStreamDropsBufferedTextWhenToolStarts(t *testing.T) { if err := stream.Push(ctx, mustPreparedMatrixEvent(t, channel.StreamEvent{Type: channel.StreamEventFinal, Final: &channel.StreamFinalizePayload{Message: channel.Message{Text: "Final answer"}}})); err != nil { t.Fatalf("push final: %v", err) } - if requests != 1 { - t.Fatalf("expected only final visible message to be sent, got %d", requests) + if requests != 4 { + t.Fatalf("expected final visible message after tool call, got %d", requests) } } diff --git a/internal/channel/adapters/qq/stream.go b/internal/channel/adapters/qq/stream.go index a06bfec7..5ae1ca56 100644 --- a/internal/channel/adapters/qq/stream.go +++ b/internal/channel/adapters/qq/stream.go @@ -62,13 +62,23 @@ func (s *qqOutboundStream) Push(ctx context.Context, event channel.PreparedStrea channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventToolCallStart, - channel.StreamEventToolCallEnd, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted, channel.StreamEventProcessingFailed: return nil + case channel.StreamEventToolCallEnd: + text := strings.TrimSpace(channel.RenderToolCallMessage(channel.BuildToolCallEnd(event.ToolCall))) + if text == "" { + return nil + } + return s.send(ctx, channel.PreparedOutboundMessage{ + Target: s.target, + Message: channel.PreparedMessage{ + Message: channel.Message{Format: channel.MessageFormatPlain, Text: text, Reply: s.reply}, + }, + }) case channel.StreamEventDelta: if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" { return nil diff --git a/internal/channel/adapters/slack/stream.go b/internal/channel/adapters/slack/stream.go index 994311b8..0b80c0a7 100644 --- a/internal/channel/adapters/slack/stream.go +++ b/internal/channel/adapters/slack/stream.go @@ -22,18 +22,19 @@ const ( ) type slackOutboundStream struct { - adapter *SlackAdapter - cfg channel.ChannelConfig - target string - reply *channel.ReplyRef - api *slackapi.Client - closed atomic.Bool - mu sync.Mutex - msgTS string // Slack message timestamp (used as message ID) - buffer strings.Builder - lastSent string - lastUpdate time.Time - nextUpdate time.Time + adapter *SlackAdapter + cfg channel.ChannelConfig + target string + reply *channel.ReplyRef + api *slackapi.Client + closed atomic.Bool + mu sync.Mutex + msgTS string // Slack message timestamp (used as message ID) + buffer strings.Builder + lastSent string + lastUpdate time.Time + nextUpdate time.Time + toolMessages map[string]string } var _ channel.PreparedOutboundStream = (*slackOutboundStream)(nil) @@ -122,11 +123,26 @@ func (s *slackOutboundStream) Push(ctx context.Context, event channel.PreparedSt } return nil + case channel.StreamEventToolCallStart: + s.mu.Lock() + bufText := strings.TrimSpace(s.buffer.String()) + s.mu.Unlock() + if bufText != "" { + if err := s.finalizeMessage(ctx, bufText); err != nil { + return err + } + } else if err := s.clearPlaceholder(ctx); err != nil { + return err + } + s.resetStreamState() + return s.sendToolCallMessage(ctx, event.ToolCall, channel.BuildToolCallStart(event.ToolCall)) + case channel.StreamEventToolCallEnd: + return s.sendToolCallMessage(ctx, event.ToolCall, channel.BuildToolCallEnd(event.ToolCall)) + case channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted, channel.StreamEventProcessingFailed, - channel.StreamEventToolCallStart, channel.StreamEventToolCallEnd, channel.StreamEventReaction, channel.StreamEventSpeech: return nil @@ -310,6 +326,80 @@ func (s *slackOutboundStream) sendAttachment(ctx context.Context, att channel.Pr return s.adapter.uploadPreparedAttachment(ctx, s.api, s.target, threadTS, att) } +// sendToolCallMessage posts a message for tool_call_start and updates the same +// message on tool_call_end via chat.update so the running → completed/failed +// transition shares one visible post. If the edit fails (or no prior message +// is tracked), it falls back to posting a new message. +func (s *slackOutboundStream) sendToolCallMessage( + ctx context.Context, + tc *channel.StreamToolCall, + p channel.ToolCallPresentation, +) error { + text := truncateSlackText(strings.TrimSpace(channel.RenderToolCallMessageMarkdown(p))) + if text == "" { + return nil + } + callID := "" + if tc != nil { + callID = strings.TrimSpace(tc.CallID) + } + if p.Status != channel.ToolCallStatusRunning && callID != "" { + if ts, ok := s.lookupToolCallMessage(callID); ok { + if err := s.updateMessageTextWithRetry(ctx, ts, text); err == nil { + s.forgetToolCallMessage(callID) + return nil + } + s.forgetToolCallMessage(callID) + } + } + ts, err := s.postMessageWithRetry(ctx, text) + if err != nil { + return err + } + if p.Status == channel.ToolCallStatusRunning && callID != "" && ts != "" { + s.storeToolCallMessage(callID, ts) + } + return nil +} + +func (s *slackOutboundStream) lookupToolCallMessage(callID string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return "", false + } + v, ok := s.toolMessages[callID] + return v, ok +} + +func (s *slackOutboundStream) storeToolCallMessage(callID, ts string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + s.toolMessages = make(map[string]string) + } + s.toolMessages[callID] = ts +} + +func (s *slackOutboundStream) forgetToolCallMessage(callID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return + } + delete(s.toolMessages, callID) +} + +func (s *slackOutboundStream) resetStreamState() { + s.mu.Lock() + s.msgTS = "" + s.buffer.Reset() + s.lastSent = "" + s.lastUpdate = time.Time{} + s.nextUpdate = time.Time{} + s.mu.Unlock() +} + func (s *slackOutboundStream) postMessageWithRetry(ctx context.Context, text string) (string, error) { opts := []slackapi.MsgOption{ slackapi.MsgOptionText(text, false), diff --git a/internal/channel/adapters/telegram/stream.go b/internal/channel/adapters/telegram/stream.go index 0bf85939..bceefab5 100644 --- a/internal/channel/adapters/telegram/stream.go +++ b/internal/channel/adapters/telegram/stream.go @@ -39,6 +39,15 @@ type telegramOutboundStream struct { streamMsgID int lastEdited string lastEditedAt time.Time + toolMessages map[string]telegramToolCallMessage +} + +// telegramToolCallMessage tracks the message posted for a tool call's +// "running" state so the matching tool_call_end event can edit the same +// message in-place to show the "completed" / "failed" state. +type telegramToolCallMessage struct { + chatID int64 + msgID int } func (s *telegramOutboundStream) getBot(_ context.Context) (bot *tgbotapi.BotAPI, err error) { @@ -306,7 +315,7 @@ func (s *telegramOutboundStream) deliverFinalText(ctx context.Context, text, par return s.editStreamMessageFinal(ctx, text) } -func (s *telegramOutboundStream) pushToolCallStart(ctx context.Context) error { +func (s *telegramOutboundStream) pushToolCallStart(ctx context.Context, tc *channel.StreamToolCall) error { s.mu.Lock() bufText := strings.TrimSpace(s.buf.String()) hasMsg := s.streamMsgID != 0 @@ -327,9 +336,120 @@ func (s *telegramOutboundStream) pushToolCallStart(ctx context.Context) error { _ = s.editStreamMessageFinal(ctx, bufText) } s.resetStreamState() + return s.sendToolCallMessage(ctx, tc, channel.BuildToolCallStart(tc)) +} + +func (s *telegramOutboundStream) pushToolCallEnd(ctx context.Context, tc *channel.StreamToolCall) error { + s.resetStreamState() + return s.sendToolCallMessage(ctx, tc, channel.BuildToolCallEnd(tc)) +} + +// renderToolCallPresentation renders a tool-call presentation to IM-ready +// text and parseMode. It prefers Markdown→HTML; falls back to plain text when +// Markdown conversion yields an empty parseMode. +func renderToolCallPresentation(p channel.ToolCallPresentation) (string, string) { + rendered := strings.TrimSpace(channel.RenderToolCallMessageMarkdown(p)) + if rendered == "" { + return "", "" + } + text, parseMode := formatTelegramOutput(rendered, channel.MessageFormatMarkdown) + if parseMode == "" { + text = strings.TrimSpace(channel.RenderToolCallMessage(p)) + } + return text, parseMode +} + +// sendToolCallMessage renders the tool-call presentation. For tool_call_start +// it posts a new message and records the callID→message mapping. For +// tool_call_end it edits the previously-posted message to flip the status to +// completed/failed. If no prior message is tracked (or editing fails), it +// falls back to sending a fresh message. +func (s *telegramOutboundStream) sendToolCallMessage( + ctx context.Context, + tc *channel.StreamToolCall, + p channel.ToolCallPresentation, +) error { + text, parseMode := renderToolCallPresentation(p) + if text == "" { + return nil + } + callID := "" + if tc != nil { + callID = strings.TrimSpace(tc.CallID) + } + if p.Status != channel.ToolCallStatusRunning && callID != "" { + if existing, ok := s.lookupToolCallMessage(callID); ok { + if err := s.adapter.waitStreamLimit(ctx); err != nil { + return err + } + bot, err := s.getBot(ctx) + if err != nil { + return err + } + editErr := error(nil) + if testEditFunc != nil { + editErr = testEditFunc(bot, existing.chatID, existing.msgID, text, parseMode) + } else { + editErr = editTelegramMessageText(bot, existing.chatID, existing.msgID, text, parseMode) + } + if editErr == nil { + s.forgetToolCallMessage(callID) + return nil + } + if s.adapter != nil && s.adapter.logger != nil { + s.adapter.logger.Warn("telegram: tool-call end edit failed, falling back to new message", + slog.String("call_id", callID), + slog.Any("error", editErr), + ) + } + s.forgetToolCallMessage(callID) + } + } + if err := s.adapter.waitStreamLimit(ctx); err != nil { + return err + } + bot, replyTo, err := s.getBotAndReply(ctx) + if err != nil { + return err + } + chatID, msgID, sendErr := sendTelegramTextReturnMessage(bot, s.target, text, replyTo, parseMode) + if sendErr != nil { + return sendErr + } + if p.Status == channel.ToolCallStatusRunning && callID != "" { + s.storeToolCallMessage(callID, telegramToolCallMessage{chatID: chatID, msgID: msgID}) + } return nil } +func (s *telegramOutboundStream) lookupToolCallMessage(callID string) (telegramToolCallMessage, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return telegramToolCallMessage{}, false + } + m, ok := s.toolMessages[callID] + return m, ok +} + +func (s *telegramOutboundStream) storeToolCallMessage(callID string, m telegramToolCallMessage) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + s.toolMessages = make(map[string]telegramToolCallMessage) + } + s.toolMessages[callID] = m +} + +func (s *telegramOutboundStream) forgetToolCallMessage(callID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.toolMessages == nil { + return + } + delete(s.toolMessages, callID) +} + func (s *telegramOutboundStream) pushAttachment(ctx context.Context, event channel.PreparedStreamEvent) error { if len(event.Attachments) == 0 { return nil @@ -489,10 +609,9 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.Prepare } switch event.Type { case channel.StreamEventToolCallStart: - return s.pushToolCallStart(ctx) + return s.pushToolCallStart(ctx, event.ToolCall) case channel.StreamEventToolCallEnd: - s.resetStreamState() - return nil + return s.pushToolCallEnd(ctx, event.ToolCall) case channel.StreamEventAttachment: return s.pushAttachment(ctx, event) case channel.StreamEventPhaseEnd: diff --git a/internal/channel/adapters/telegram/stream_test.go b/internal/channel/adapters/telegram/stream_test.go index 9d6593f2..95e6e74c 100644 --- a/internal/channel/adapters/telegram/stream_test.go +++ b/internal/channel/adapters/telegram/stream_test.go @@ -553,6 +553,149 @@ func TestDraftMode_PhaseEndTextIsNoOp(t *testing.T) { } } +// TestToolCallFlow_ThreeMessagesPerCall verifies that a single tool call +// combined with pre-existing streamed text produces three distinct messages: +// (1) flush of buffered pre-text, (2) running state for the tool call, +// (3) completed / failed state for the tool call. The streaming state must be +// reset between the flush and the start, then tool_call_end edits the running +// message in place so one tool call produces exactly one tool-call message. +func TestToolCallFlow_FlushPreTextAndEditRunning(t *testing.T) { + adapter := NewTelegramAdapter(nil) + s := &telegramOutboundStream{ + adapter: adapter, + cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}}, + target: "123", + streamChatID: 42, + streamMsgID: 7, + } + s.buf.WriteString("pre-tool text") + ctx := context.Background() + + origGetBot := getOrCreateBotForTest + origSendText := sendTextForTest + origEdit := testEditFunc + getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) { + return &tgbotapi.BotAPI{Token: "fake"}, nil + } + var sentTexts []string + var msgIDCounter int + sendTextForTest = func(_ *tgbotapi.BotAPI, _ string, text string, _ int, _ string) (int64, int, error) { + sentTexts = append(sentTexts, text) + msgIDCounter++ + return 42, msgIDCounter, nil + } + var editTexts []string + testEditFunc = func(_ *tgbotapi.BotAPI, _ int64, _ int, text string, _ string) error { + editTexts = append(editTexts, text) + return nil + } + defer func() { + getOrCreateBotForTest = origGetBot + sendTextForTest = origSendText + testEditFunc = origEdit + }() + + tcStart := &channel.StreamToolCall{Name: "read", CallID: "call_1", Input: map[string]any{"path": "/tmp/a"}} + tcEnd := &channel.StreamToolCall{Name: "read", CallID: "call_1", Input: map[string]any{"path": "/tmp/a"}, Result: map[string]any{"ok": true}} + + if err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallStart, ToolCall: tcStart})); err != nil { + t.Fatalf("push start: %v", err) + } + + s.mu.Lock() + streamMsgAfterStart := s.streamMsgID + bufAfterStart := s.buf.String() + s.mu.Unlock() + if streamMsgAfterStart != 0 { + t.Fatalf("streamMsgID should be reset after tool_call_start, got %d", streamMsgAfterStart) + } + if bufAfterStart != "" { + t.Fatalf("buf should be reset after tool_call_start, got %q", bufAfterStart) + } + + if err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallEnd, ToolCall: tcEnd})); err != nil { + t.Fatalf("push end: %v", err) + } + + // Edits: 1 for pre-text flush, 1 for running → completed. + if len(editTexts) != 2 { + t.Fatalf("expected exactly 2 edits (pre-text flush + running→completed), got %d: %v", len(editTexts), editTexts) + } + if !strings.Contains(editTexts[0], "pre-tool text") { + t.Fatalf("first edit should be the pre-text flush: %q", editTexts[0]) + } + if !strings.Contains(editTexts[1], "completed") { + t.Fatalf("second edit should flip the tool call to completed: %q", editTexts[1]) + } + if len(sentTexts) != 1 { + t.Fatalf("expected exactly 1 send (running), got %d: %v", len(sentTexts), sentTexts) + } + if !strings.Contains(sentTexts[0], "running") { + t.Fatalf("only send should be the running state: %q", sentTexts[0]) + } +} + +// TestToolCallFlow_NoPreTextEditsRunningInPlace verifies that when no text +// stream is active, tool_call_start sends the running message and +// tool_call_end edits it in place — no pre-text flush, one send, one edit. +func TestToolCallFlow_NoPreTextEditsRunningInPlace(t *testing.T) { + adapter := NewTelegramAdapter(nil) + s := &telegramOutboundStream{ + adapter: adapter, + cfg: channel.ChannelConfig{ID: "test", Credentials: map[string]any{"bot_token": "fake"}}, + target: "123", + streamChatID: 42, + } + ctx := context.Background() + + origGetBot := getOrCreateBotForTest + origSendText := sendTextForTest + origEdit := testEditFunc + getOrCreateBotForTest = func(_ *TelegramAdapter, _, _ string) (*tgbotapi.BotAPI, error) { + return &tgbotapi.BotAPI{Token: "fake"}, nil + } + var sentTexts []string + var msgIDCounter int + sendTextForTest = func(_ *tgbotapi.BotAPI, _ string, text string, _ int, _ string) (int64, int, error) { + sentTexts = append(sentTexts, text) + msgIDCounter++ + return 42, msgIDCounter, nil + } + var editTexts []string + testEditFunc = func(_ *tgbotapi.BotAPI, _ int64, _ int, text string, _ string) error { + editTexts = append(editTexts, text) + return nil + } + defer func() { + getOrCreateBotForTest = origGetBot + sendTextForTest = origSendText + testEditFunc = origEdit + }() + + tcStart := &channel.StreamToolCall{Name: "read", CallID: "call_1", Input: map[string]any{"path": "/tmp/a"}} + tcEnd := &channel.StreamToolCall{Name: "read", CallID: "call_1", Result: map[string]any{"ok": true}} + + if err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallStart, ToolCall: tcStart})); err != nil { + t.Fatalf("push start: %v", err) + } + if err := s.Push(ctx, mustPreparedTelegramEvent(t, channel.StreamEvent{Type: channel.StreamEventToolCallEnd, ToolCall: tcEnd})); err != nil { + t.Fatalf("push end: %v", err) + } + + if len(sentTexts) != 1 { + t.Fatalf("expected exactly 1 send (running), got %d: %v", len(sentTexts), sentTexts) + } + if !strings.Contains(sentTexts[0], "running") { + t.Fatalf("only send should be the running state: %q", sentTexts[0]) + } + if len(editTexts) != 1 { + t.Fatalf("expected exactly 1 edit (running→completed), got %d: %v", len(editTexts), editTexts) + } + if !strings.Contains(editTexts[0], "completed") { + t.Fatalf("edit should flip the tool call to completed: %q", editTexts[0]) + } +} + func TestDraftMode_ToolCallStartSendsPermanentMessage(t *testing.T) { adapter := NewTelegramAdapter(nil) s := &telegramOutboundStream{ diff --git a/internal/channel/adapters/wecom/wecom.go b/internal/channel/adapters/wecom/wecom.go index c26d6f3e..86326830 100644 --- a/internal/channel/adapters/wecom/wecom.go +++ b/internal/channel/adapters/wecom/wecom.go @@ -278,13 +278,14 @@ func (s *wecomOutboundStream) Push(ctx context.Context, event channel.PreparedSt channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventToolCallStart, - channel.StreamEventToolCallEnd, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted, channel.StreamEventProcessingFailed: return nil + case channel.StreamEventToolCallEnd: + return s.sendToolCallSummary(ctx, event.ToolCall) case channel.StreamEventDelta: if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning { return nil @@ -363,6 +364,27 @@ func (s *wecomOutboundStream) flush(ctx context.Context) error { return nil } +// sendToolCallSummary emits a best-effort terminal summary of a tool call. +// WeCom lacks message-edit APIs in the one-shot send path, so only the +// completed / failed state is surfaced — the running state is intentionally +// suppressed to avoid duplicate messages. +func (s *wecomOutboundStream) sendToolCallSummary(ctx context.Context, tc *channel.StreamToolCall) error { + if s.finalSent.Load() { + return nil + } + text := strings.TrimSpace(channel.RenderToolCallMessage(channel.BuildToolCallEnd(tc))) + if text == "" { + return nil + } + msg := channel.PreparedMessage{ + Message: channel.Message{Format: channel.MessageFormatPlain, Text: text}, + } + return s.adapter.Send(ctx, s.cfg, channel.PreparedOutboundMessage{ + Target: s.target, + Message: msg, + }) +} + func (s *wecomOutboundStream) pushPreview(ctx context.Context) error { if s.finalSent.Load() { return nil diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index 6413816e..f9e0df12 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -93,6 +93,15 @@ type SessionEnsurer interface { CreateNewSession(ctx context.Context, botID, routeID, channelType, sessionType string) (SessionResult, error) } +// IMDisplayOptionsReader exposes bot-level IM display preferences. +// Implementations typically adapt the settings service. +type IMDisplayOptionsReader interface { + // ShowToolCallsInIM reports whether tool_call lifecycle events should + // reach IM adapters for the given bot. Returns false by default when the + // bot or its settings cannot be resolved. + ShowToolCallsInIM(ctx context.Context, botID string) (bool, error) +} + // SessionResult carries the minimum fields needed from a session. type SessionResult struct { ID string @@ -124,6 +133,7 @@ type ChannelInboundProcessor struct { pipeline *pipelinepkg.Pipeline eventStore *pipelinepkg.EventStore discussDriver *pipelinepkg.DiscussDriver + imDisplayOptions IMDisplayOptionsReader // activeStreams maps "botID:routeID" to a context.CancelFunc for the // currently running agent stream. Used by /stop to abort generation @@ -259,6 +269,42 @@ func (p *ChannelInboundProcessor) SetDispatcher(dispatcher *RouteDispatcher) { p.dispatcher = dispatcher } +// SetIMDisplayOptions configures the reader used to gate IM-facing stream +// events (e.g. tool call lifecycle) on bot-level display preferences. When +// nil, tool call events are always dropped before reaching IM adapters. +func (p *ChannelInboundProcessor) SetIMDisplayOptions(reader IMDisplayOptionsReader) { + if p == nil { + return + } + p.imDisplayOptions = reader +} + +// shouldShowToolCallsInIM reports whether tool_call_start / tool_call_end +// events should reach the IM adapter for the given bot. Failures and missing +// configuration default to false so tool calls remain hidden unless explicitly +// enabled. +func (p *ChannelInboundProcessor) shouldShowToolCallsInIM(ctx context.Context, botID string) bool { + if p == nil || p.imDisplayOptions == nil { + return false + } + botID = strings.TrimSpace(botID) + if botID == "" { + return false + } + show, err := p.imDisplayOptions.ShowToolCallsInIM(ctx, botID) + if err != nil { + if p.logger != nil { + p.logger.Debug( + "show_tool_calls_in_im lookup failed, defaulting to hidden", + slog.String("bot_id", botID), + slog.Any("error", err), + ) + } + return false + } + return show +} + // HandleInbound processes an inbound channel message through identity resolution and chat gateway. func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, sender channel.StreamReplySender) (retErr error) { if p.runner == nil { @@ -721,6 +767,14 @@ startStream: } }() + // For non-local channels (IM adapters), optionally drop tool_call events + // before they reach the adapter when the bot's show_tool_calls_in_im + // setting is off. The filter sits inside the TeeStream so WebUI + // observers still receive the full event stream. + if !isLocalChannelType(msg.Channel) && !p.shouldShowToolCallsInIM(ctx, identity.BotID) { + stream = channel.NewToolCallDroppingStream(stream) + } + // For non-local channels, wrap the stream so events are mirrored to the // RouteHub (and thus to Web UI and other local subscribers). if p.observer != nil && !isLocalChannelType(msg.Channel) { diff --git a/internal/channel/toolcall_filter.go b/internal/channel/toolcall_filter.go new file mode 100644 index 00000000..e252bd70 --- /dev/null +++ b/internal/channel/toolcall_filter.go @@ -0,0 +1,39 @@ +package channel + +import "context" + +// toolCallDroppingStream drops tool_call_start / tool_call_end events while +// forwarding every other event to the wrapped primary stream unchanged. This +// is used to gate IM-facing streams when a bot's show_tool_calls_in_im setting +// is off: the IM adapter stops receiving tool lifecycle events, but any +// upstream TeeStream observer (e.g. the WebUI hub) still sees them because +// the tee mirrors events independently. +type toolCallDroppingStream struct { + primary OutboundStream +} + +// NewToolCallDroppingStream wraps primary and drops tool_call_start / +// tool_call_end events. When primary is nil the function returns nil. +func NewToolCallDroppingStream(primary OutboundStream) OutboundStream { + if primary == nil { + return nil + } + return &toolCallDroppingStream{primary: primary} +} + +func (s *toolCallDroppingStream) Push(ctx context.Context, event StreamEvent) error { + if s == nil || s.primary == nil { + return nil + } + if event.Type == StreamEventToolCallStart || event.Type == StreamEventToolCallEnd { + return nil + } + return s.primary.Push(ctx, event) +} + +func (s *toolCallDroppingStream) Close(ctx context.Context) error { + if s == nil || s.primary == nil { + return nil + } + return s.primary.Close(ctx) +} diff --git a/internal/channel/toolcall_filter_test.go b/internal/channel/toolcall_filter_test.go new file mode 100644 index 00000000..e2fa95db --- /dev/null +++ b/internal/channel/toolcall_filter_test.go @@ -0,0 +1,89 @@ +package channel + +import ( + "context" + "errors" + "testing" +) + +type recordingOutboundStream struct { + events []StreamEvent + closed bool + err error +} + +func (r *recordingOutboundStream) Push(_ context.Context, event StreamEvent) error { + if r.err != nil { + return r.err + } + r.events = append(r.events, event) + return nil +} + +func (r *recordingOutboundStream) Close(_ context.Context) error { + r.closed = true + return nil +} + +func TestToolCallDroppingStreamFiltersToolEvents(t *testing.T) { + t.Parallel() + + sink := &recordingOutboundStream{} + stream := NewToolCallDroppingStream(sink) + if stream == nil { + t.Fatalf("expected non-nil wrapper") + } + + ctx := context.Background() + events := []StreamEvent{ + {Type: StreamEventDelta, Delta: "hi"}, + {Type: StreamEventToolCallStart, ToolCall: &StreamToolCall{Name: "read", CallID: "c1"}}, + {Type: StreamEventToolCallEnd, ToolCall: &StreamToolCall{Name: "read", CallID: "c1"}}, + {Type: StreamEventStatus, Status: StreamStatusCompleted}, + } + for _, e := range events { + if err := stream.Push(ctx, e); err != nil { + t.Fatalf("push %s: %v", e.Type, err) + } + } + if len(sink.events) != 2 { + t.Fatalf("expected 2 forwarded events, got %d: %+v", len(sink.events), sink.events) + } + if sink.events[0].Type != StreamEventDelta { + t.Fatalf("expected delta first, got %s", sink.events[0].Type) + } + if sink.events[1].Type != StreamEventStatus { + t.Fatalf("expected status second, got %s", sink.events[1].Type) + } + + if err := stream.Close(ctx); err != nil { + t.Fatalf("close: %v", err) + } + if !sink.closed { + t.Fatalf("expected primary close to be called") + } +} + +func TestToolCallDroppingStreamForwardsPrimaryError(t *testing.T) { + t.Parallel() + + boom := errors.New("boom") + stream := NewToolCallDroppingStream(&recordingOutboundStream{err: boom}) + err := stream.Push(context.Background(), StreamEvent{Type: StreamEventDelta, Delta: "x"}) + if !errors.Is(err, boom) { + t.Fatalf("expected primary error to surface, got %v", err) + } + + // tool events should still be dropped silently without calling the primary + if err := stream.Push(context.Background(), StreamEvent{Type: StreamEventToolCallStart}); err != nil { + t.Fatalf("tool event should not propagate primary error, got %v", err) + } +} + +func TestNewToolCallDroppingStreamNilPrimary(t *testing.T) { + t.Parallel() + + if got := NewToolCallDroppingStream(nil); got != nil { + t.Fatalf("expected nil wrapper when primary is nil, got %T", got) + } +} diff --git a/internal/channel/toolcall_format.go b/internal/channel/toolcall_format.go new file mode 100644 index 00000000..d14ba5be --- /dev/null +++ b/internal/channel/toolcall_format.go @@ -0,0 +1,333 @@ +package channel + +import ( + "strings" +) + +// ToolCallStatus is the lifecycle state of a single tool call as surfaced in IM. +type ToolCallStatus string + +const ( + ToolCallStatusRunning ToolCallStatus = "running" + ToolCallStatusCompleted ToolCallStatus = "completed" + ToolCallStatusFailed ToolCallStatus = "failed" +) + +// ExternalToolCallEmoji is the emoji used for any tool not in the built-in +// whitelist (including MCP and federation tools). +const ExternalToolCallEmoji = "⚙️" + +// builtinToolCallEmoji maps built-in tool names to their display emoji. +// Names are matched case-insensitively after trimming whitespace. +var builtinToolCallEmoji = map[string]string{ + "list": "📂", + "read": "📖", + "write": "📝", + "edit": "📝", + "exec": "💻", + "bg_status": "💻", + "web_search": "🌐", + "web_fetch": "🌐", + + "search_memory": "🧠", + "search_messages": "🧠", + "list_sessions": "🧠", + + "list_schedule": "📅", + "get_schedule": "📅", + "create_schedule": "📅", + "update_schedule": "📅", + "delete_schedule": "📅", + + "send": "💬", + "react": "💬", + + "get_contacts": "👥", + + "list_email_accounts": "📧", + "send_email": "📧", + "list_email": "📧", + "read_email": "📧", + + "browser_action": "🧭", + "browser_observe": "🧭", + "browser_remote_session": "🧭", + + "spawn": "🤖", + "use_skill": "🧩", + + "generate_image": "🖼️", + "speak": "🔊", + "transcribe_audio": "🎧", +} + +// ToolCallEmoji returns the emoji mapped for a tool name. Unknown / external +// tools fall back to ExternalToolCallEmoji. +func ToolCallEmoji(toolName string) string { + key := strings.ToLower(strings.TrimSpace(toolName)) + if emoji, ok := builtinToolCallEmoji[key]; ok { + return emoji + } + return ExternalToolCallEmoji +} + +// ToolCallBlockType distinguishes body block rendering semantics. +type ToolCallBlockType string + +const ( + ToolCallBlockText ToolCallBlockType = "text" // free-form line or paragraph + ToolCallBlockLink ToolCallBlockType = "link" // titled hyperlink, optional description + ToolCallBlockCode ToolCallBlockType = "code" // preformatted / code block +) + +// ToolCallBlock is one rich element inside ToolCallPresentation.Body. Fields +// not applicable to the Type are ignored. +type ToolCallBlock struct { + Type ToolCallBlockType + Title string + URL string + Desc string + Text string +} + +// ToolCallPresentation is the rendered single-message view of one tool call +// state. Adapters call RenderToolCallMessage (or their own renderer) against +// this struct to produce the final IM text body. +// +// The preferred fields are Header / Body / Footer, populated either by +// per-tool formatters (see toolcall_formatters.go) or by the generic builder. +// InputSummary / ResultSummary are retained so existing callers that expect +// two flat strings keep working. +type ToolCallPresentation struct { + Emoji string + ToolName string + Status ToolCallStatus + + Header string + Body []ToolCallBlock + Footer string + + InputSummary string + ResultSummary string +} + +// BuildToolCallStart builds a presentation for a tool_call_start event. +// Returns a zero-value presentation when the payload is nil. +func BuildToolCallStart(tc *StreamToolCall) ToolCallPresentation { + if tc == nil { + return ToolCallPresentation{} + } + name := strings.TrimSpace(tc.Name) + if fn := lookupToolFormatter(name); fn != nil { + p := fn(tc, ToolCallStatusRunning) + fillBaseIdentity(&p, name, ToolCallStatusRunning) + return p + } + summary := SummarizeToolInput(name, tc.Input) + return ToolCallPresentation{ + Emoji: ToolCallEmoji(name), + ToolName: name, + Status: ToolCallStatusRunning, + Header: summary, + InputSummary: summary, + } +} + +// BuildToolCallEnd builds a presentation for a tool_call_end event. The +// completed / failed status is inferred from the tool result payload (ok=false, +// error fields, non-zero exit codes, etc.). +func BuildToolCallEnd(tc *StreamToolCall) ToolCallPresentation { + if tc == nil { + return ToolCallPresentation{} + } + name := strings.TrimSpace(tc.Name) + status := ToolCallStatusCompleted + if isToolResultFailure(tc.Result) { + status = ToolCallStatusFailed + } + if fn := lookupToolFormatter(name); fn != nil { + p := fn(tc, status) + fillBaseIdentity(&p, name, status) + return p + } + inputSummary := SummarizeToolInput(name, tc.Input) + resultSummary := SummarizeToolResult(name, tc.Result) + return ToolCallPresentation{ + Emoji: ToolCallEmoji(name), + ToolName: name, + Status: status, + Header: inputSummary, + Footer: resultSummary, + InputSummary: inputSummary, + ResultSummary: resultSummary, + } +} + +// fillBaseIdentity fills emoji / tool name / status after a per-tool +// formatter runs, without clobbering values set by the formatter itself. +// InputSummary / ResultSummary are intentionally NOT populated here: when a +// formatter is used, its Header / Body / Footer output is authoritative and +// we must not append raw JSON summaries as a fallback. +func fillBaseIdentity(p *ToolCallPresentation, name string, status ToolCallStatus) { + if p.Emoji == "" { + p.Emoji = ToolCallEmoji(name) + } + if p.ToolName == "" { + p.ToolName = name + } + if p.Status == "" { + p.Status = status + } +} + +// RenderToolCallMessage renders a plain-text single-message view of a tool +// call state. Links are rendered as two lines: the title on one line and the +// URL on the next. Adapters that want Markdown link syntax should use +// RenderToolCallMessageMarkdown instead. +func RenderToolCallMessage(p ToolCallPresentation) string { + return renderToolCall(p, false) +} + +// RenderToolCallMessageMarkdown renders a Markdown version of the tool call +// presentation. Links become [title](url), code blocks are fenced with triple +// backticks, and plain-text blocks are unchanged. +func RenderToolCallMessageMarkdown(p ToolCallPresentation) string { + return renderToolCall(p, true) +} + +func renderToolCall(p ToolCallPresentation, markdown bool) string { + if !presentationHasContent(p) { + return "" + } + var b strings.Builder + + emoji := p.Emoji + if emoji == "" { + emoji = ExternalToolCallEmoji + } + b.WriteString(emoji) + b.WriteString(" ") + if p.ToolName != "" { + b.WriteString(p.ToolName) + } else { + b.WriteString("tool") + } + if p.Status != "" { + b.WriteString(" · ") + b.WriteString(string(p.Status)) + } + + header := strings.TrimSpace(p.Header) + if header == "" { + header = strings.TrimSpace(p.InputSummary) + } + if header != "" { + b.WriteString("\n") + b.WriteString(header) + } + + for _, block := range p.Body { + rendered := renderToolCallBlock(block, markdown) + if rendered == "" { + continue + } + b.WriteString("\n") + b.WriteString(rendered) + } + + footer := strings.TrimSpace(p.Footer) + if footer == "" { + footer = strings.TrimSpace(p.ResultSummary) + } + if footer != "" { + b.WriteString("\n") + b.WriteString(footer) + } + + return b.String() +} + +func presentationHasContent(p ToolCallPresentation) bool { + if p.ToolName != "" || p.Emoji != "" { + return true + } + if strings.TrimSpace(p.Header) != "" { + return true + } + if strings.TrimSpace(p.Footer) != "" { + return true + } + if strings.TrimSpace(p.InputSummary) != "" { + return true + } + if strings.TrimSpace(p.ResultSummary) != "" { + return true + } + return len(p.Body) > 0 +} + +func renderToolCallBlock(block ToolCallBlock, markdown bool) string { + switch block.Type { + case ToolCallBlockLink: + return renderLinkBlock(block, markdown) + case ToolCallBlockCode: + return renderCodeBlock(block, markdown) + case ToolCallBlockText: + return strings.TrimRight(block.Text, "\n") + default: + // Unknown types: fall back to Text for resilience. + return strings.TrimRight(block.Text, "\n") + } +} + +func renderLinkBlock(block ToolCallBlock, markdown bool) string { + title := strings.TrimSpace(block.Title) + url := strings.TrimSpace(block.URL) + desc := strings.TrimSpace(block.Desc) + + var b strings.Builder + switch { + case markdown && url != "": + label := title + if label == "" { + label = url + } + b.WriteString("[") + b.WriteString(label) + b.WriteString("](") + b.WriteString(url) + b.WriteString(")") + case url != "" && title != "": + b.WriteString(title) + b.WriteString("\n") + b.WriteString(url) + case url != "": + b.WriteString(url) + case title != "": + b.WriteString(title) + } + + if desc != "" { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(desc) + } + return b.String() +} + +func renderCodeBlock(block ToolCallBlock, markdown bool) string { + text := strings.TrimRight(block.Text, "\n") + if text == "" { + return "" + } + if !markdown { + return text + } + var b strings.Builder + b.WriteString("```") + b.WriteString("\n") + b.WriteString(text) + b.WriteString("\n```") + return b.String() +} diff --git a/internal/channel/toolcall_format_test.go b/internal/channel/toolcall_format_test.go new file mode 100644 index 00000000..e17ef290 --- /dev/null +++ b/internal/channel/toolcall_format_test.go @@ -0,0 +1,213 @@ +package channel + +import ( + "reflect" + "strings" + "testing" +) + +func TestToolCallEmojiBuiltin(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "read": "📖", + "WRITE": "📝", + " edit ": "📝", + "exec": "💻", + "web_search": "🌐", + "search_memory": "🧠", + "list_schedule": "📅", + "send": "💬", + "get_contacts": "👥", + "send_email": "📧", + "browser_action": "🧭", + "spawn": "🤖", + "use_skill": "🧩", + "generate_image": "🖼️", + "speak": "🔊", + } + + for name, want := range cases { + if got := ToolCallEmoji(name); got != want { + t.Fatalf("ToolCallEmoji(%q) = %q, want %q", name, got, want) + } + } +} + +func TestToolCallEmojiExternalFallback(t *testing.T) { + t.Parallel() + + for _, name := range []string{"", " ", "mcp.filesystem.read", "federation_foo", "unknown_tool"} { + if got := ToolCallEmoji(name); got != ExternalToolCallEmoji { + t.Fatalf("ToolCallEmoji(%q) = %q, want external %q", name, got, ExternalToolCallEmoji) + } + } +} + +func TestBuildToolCallStartPopulatesRunning(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "read", + CallID: "call_1", + Input: map[string]any{"path": "/tmp/foo.txt"}, + } + p := BuildToolCallStart(tc) + if p.Status != ToolCallStatusRunning { + t.Fatalf("unexpected status: %q", p.Status) + } + if p.Emoji != "📖" { + t.Fatalf("unexpected emoji: %q", p.Emoji) + } + if p.Header != "/tmp/foo.txt" { + t.Fatalf("unexpected header: %q", p.Header) + } + if p.ResultSummary != "" { + t.Fatalf("start presentation should not carry a result summary, got %q", p.ResultSummary) + } + if p.Footer != "" { + t.Fatalf("start presentation should not carry a footer, got %q", p.Footer) + } +} + +func TestBuildToolCallEndInfersStatus(t *testing.T) { + t.Parallel() + + ok := &StreamToolCall{Name: "exec", Input: map[string]any{"command": "ls -la"}, Result: map[string]any{"ok": true, "exit_code": 0}} + if got := BuildToolCallEnd(ok); got.Status != ToolCallStatusCompleted { + t.Fatalf("expected completed, got %q", got.Status) + } + + fail := &StreamToolCall{Name: "exec", Input: map[string]any{"command": "false"}, Result: map[string]any{"exit_code": 2, "stderr": "boom"}} + if got := BuildToolCallEnd(fail); got.Status != ToolCallStatusFailed { + t.Fatalf("expected failed, got %q", got.Status) + } + + errored := &StreamToolCall{Name: "read", Input: map[string]any{"path": "/missing"}, Result: map[string]any{"error": "ENOENT"}} + if got := BuildToolCallEnd(errored); got.Status != ToolCallStatusFailed { + t.Fatalf("expected failed on error, got %q", got.Status) + } +} + +func TestBuildToolCallHandlesNil(t *testing.T) { + t.Parallel() + + if got := BuildToolCallStart(nil); !reflect.DeepEqual(got, ToolCallPresentation{}) { + t.Fatalf("expected zero-value presentation for nil start, got %+v", got) + } + if got := BuildToolCallEnd(nil); !reflect.DeepEqual(got, ToolCallPresentation{}) { + t.Fatalf("expected zero-value presentation for nil end, got %+v", got) + } +} + +func TestRenderToolCallMessageLayout(t *testing.T) { + t.Parallel() + + msg := RenderToolCallMessage(ToolCallPresentation{ + Emoji: "📖", + ToolName: "read", + Status: ToolCallStatusRunning, + InputSummary: "/tmp/foo.txt", + ResultSummary: "", + }) + if !strings.HasPrefix(msg, "📖 read · running") { + t.Fatalf("unexpected header: %q", msg) + } + if !strings.Contains(msg, "/tmp/foo.txt") { + t.Fatalf("expected input summary in body: %q", msg) + } + + done := RenderToolCallMessage(ToolCallPresentation{ + Emoji: "💻", + ToolName: "exec", + Status: ToolCallStatusCompleted, + InputSummary: "ls -la", + ResultSummary: "exit=0 · stdout: total 0", + }) + lines := strings.Split(done, "\n") + if len(lines) != 3 { + t.Fatalf("expected header+input+result lines, got %d: %q", len(lines), done) + } + if !strings.HasPrefix(lines[0], "💻 exec · completed") { + t.Fatalf("unexpected header: %q", lines[0]) + } +} + +func TestRenderToolCallMessageEmptyWhenNothingKnown(t *testing.T) { + t.Parallel() + + if got := RenderToolCallMessage(ToolCallPresentation{}); got != "" { + t.Fatalf("expected empty render, got %q", got) + } +} + +func TestRenderToolCallMessageMarkdownRendersLinks(t *testing.T) { + t.Parallel() + + p := ToolCallPresentation{ + Emoji: "🌐", + ToolName: "web_search", + Status: ToolCallStatusCompleted, + Header: `2 results for "golang generics"`, + Body: []ToolCallBlock{ + { + Type: ToolCallBlockLink, + Title: "Tutorial: Getting started with generics", + URL: "https://go.dev/doc/tutorial/generics", + Desc: "A comprehensive walkthrough", + }, + { + Type: ToolCallBlockLink, + Title: "Go 1.18 is released", + URL: "https://go.dev/blog/go1.18", + }, + }, + } + + md := RenderToolCallMessageMarkdown(p) + if !strings.Contains(md, "[Tutorial: Getting started with generics](https://go.dev/doc/tutorial/generics)") { + t.Fatalf("expected markdown link for first item, got %q", md) + } + if !strings.Contains(md, "[Go 1.18 is released](https://go.dev/blog/go1.18)") { + t.Fatalf("expected markdown link for second item, got %q", md) + } + if !strings.Contains(md, "A comprehensive walkthrough") { + t.Fatalf("expected description to appear in markdown, got %q", md) + } + + plain := RenderToolCallMessage(p) + if strings.Contains(plain, "](https://") { + t.Fatalf("plain render should not contain markdown link syntax, got %q", plain) + } + if !strings.Contains(plain, "Tutorial: Getting started with generics") || !strings.Contains(plain, "https://go.dev/doc/tutorial/generics") { + t.Fatalf("plain render should carry title and url lines, got %q", plain) + } +} + +func TestRenderToolCallMessageMarkdownCodeBlocks(t *testing.T) { + t.Parallel() + + p := ToolCallPresentation{ + Emoji: "💻", + ToolName: "exec", + Status: ToolCallStatusCompleted, + Header: "$ ls -la", + Body: []ToolCallBlock{ + {Type: ToolCallBlockCode, Text: "total 0\ndrwxr-xr-x 2 user"}, + }, + Footer: "exit=0", + } + + md := RenderToolCallMessageMarkdown(p) + if !strings.Contains(md, "```\ntotal 0\ndrwxr-xr-x 2 user\n```") { + t.Fatalf("expected fenced code block, got %q", md) + } + + plain := RenderToolCallMessage(p) + if strings.Contains(plain, "```") { + t.Fatalf("plain render should not fence code, got %q", plain) + } + if !strings.Contains(plain, "total 0") { + t.Fatalf("plain render should still include code body, got %q", plain) + } +} diff --git a/internal/channel/toolcall_formatters.go b/internal/channel/toolcall_formatters.go new file mode 100644 index 00000000..07140a9b --- /dev/null +++ b/internal/channel/toolcall_formatters.go @@ -0,0 +1,1200 @@ +package channel + +import ( + "fmt" + "strings" + + "github.com/memohai/memoh/internal/textutil" +) + +// toolFormatter produces a structured presentation for a specific built-in +// tool. Status is already inferred by the caller (running / completed / +// failed); the formatter may choose to populate Header, Body, Footer, +// InputSummary, ResultSummary, or any subset. Emoji / ToolName / Status are +// filled by the caller if left empty. +type toolFormatter func(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation + +// toolFormatters registers the per-tool renderers. Missing entries fall back +// to the generic SummarizeToolInput / SummarizeToolResult helpers. +var toolFormatters = map[string]toolFormatter{ + "list": formatList, + "read": formatRead, + "write": formatWrite, + "edit": formatEdit, + + "exec": formatExec, + "bg_status": formatBgStatus, + + "web_search": formatWebSearch, + "web_fetch": formatWebFetch, + + "search_memory": formatSearchMemory, + "search_messages": formatSearchMessages, + "list_sessions": formatListSessions, + + "list_schedule": formatListSchedule, + "get_schedule": formatGetSchedule, + "create_schedule": formatCreateSchedule, + "update_schedule": formatUpdateSchedule, + "delete_schedule": formatDeleteSchedule, + + "send": formatSend, + "react": formatReact, + + "get_contacts": formatGetContacts, + + "list_email_accounts": formatListEmailAccounts, + "send_email": formatSendEmail, + "list_email": formatListEmail, + "read_email": formatReadEmail, + + "browser_action": formatBrowserAction, + "browser_observe": formatBrowserObserve, + "browser_remote_session": formatBrowserRemoteSession, + + "spawn": formatSpawn, + "use_skill": formatUseSkill, + + "generate_image": formatGenerateImage, + "speak": formatSpeak, + "transcribe_audio": formatTranscribeAudio, +} + +func lookupToolFormatter(name string) toolFormatter { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + return nil + } + return toolFormatters[key] +} + +// --- helpers ------------------------------------------------------------ + +func inputMap(tc *StreamToolCall) map[string]any { + if tc == nil { + return nil + } + m, _ := normalizeToMap(tc.Input) + return m +} + +func resultMap(tc *StreamToolCall) map[string]any { + if tc == nil { + return nil + } + m, _ := normalizeToMap(tc.Result) + return m +} + +func errorPresentation(p ToolCallPresentation, status ToolCallStatus, tc *StreamToolCall) (ToolCallPresentation, bool) { + if status != ToolCallStatusFailed { + return p, false + } + errText := "" + if res := resultMap(tc); res != nil { + errText = pickStringField(res, "error", "message", "stderr") + } + if errText == "" { + if tc != nil { + if s, ok := tc.Result.(string); ok { + errText = strings.TrimSpace(s) + } + } + } + if errText == "" { + errText = "failed" + } + p.Footer = "error: " + truncateSummary(errText) + return p, true +} + +func truncLine(s string) string { + return textutil.TruncateRunesWithSuffix(strings.TrimSpace(s), 200, toolCallSummaryTruncMark) +} + +func asSliceOfMaps(v any) []map[string]any { + if v == nil { + return nil + } + switch arr := v.(type) { + case []any: + out := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + if m, ok := normalizeToMap(item); ok { + out = append(out, m) + } + } + return out + case []map[string]any: + return arr + } + return nil +} + +// --- file tools --------------------------------------------------------- + +func formatList(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + path := "" + if in != nil { + path = pickStringField(in, "path") + if path == "" { + path = "." + } + } + p := ToolCallPresentation{Header: path} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + entries := asSliceOfMaps(res["entries"]) + total := 0 + if v, ok := numericField(res, "total_count"); ok { + total = int(v) + } else { + total = len(entries) + } + if total > 0 { + p.Header = fmt.Sprintf("%s · %d entries", path, total) + } + preview := 5 + shown := 0 + for _, e := range entries { + if shown >= preview { + break + } + name := pickStringField(e, "path") + if name == "" { + continue + } + isDir := false + if b, ok := e["is_dir"].(bool); ok { + isDir = b + } + kind := "file" + if isDir { + kind = "dir" + name = strings.TrimRight(name, "/") + "/" + } + size := "" + if sz, ok := numericField(e, "size"); ok && !isDir { + size = humanSize(int64(sz)) + } + var text string + switch { + case isDir: + text = fmt.Sprintf("- %s (%s)", name, kind) + case size != "": + text = fmt.Sprintf("- %s (%s, %s)", name, kind, size) + default: + text = fmt.Sprintf("- %s (%s)", name, kind) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: text}) + shown++ + } + if total > shown { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("…and %d more", total-shown)}) + } + return p +} + +func formatRead(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + path := pickStringField(in, "path", "file_path", "filepath") + p := ToolCallPresentation{Header: path} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + if lines, ok := numericField(res, "total_lines"); ok && path != "" { + p.Header = fmt.Sprintf("%s · %d lines", path, int(lines)) + } + return p +} + +func formatWrite(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + path := pickStringField(in, "path", "file_path", "filepath") + p := ToolCallPresentation{Header: path} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + return p +} + +func formatEdit(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + path := pickStringField(in, "path", "file_path", "filepath") + p := ToolCallPresentation{Header: path} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + if changes, ok := numericField(res, "changes"); ok && int(changes) > 0 && path != "" { + p.Header = fmt.Sprintf("%s · %d changes", path, int(changes)) + } + return p +} + +// --- exec / bg_status -------------------------------------------------- + +func formatExec(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + cmd := pickStringField(in, "command", "cmd") + first := firstLine(cmd) + p := ToolCallPresentation{Header: "$ " + first} + if first == "" { + p.Header = "" + } + if status == ToolCallStatusRunning { + return p + } + res := resultMap(tc) + if res == nil { + if e, done := errorPresentation(p, status, tc); done { + return e + } + return p + } + if st := pickStringField(res, "status"); st == "background_started" || st == "auto_backgrounded" { + taskID := pickStringField(res, "task_id") + out := pickStringField(res, "output_file") + parts := []string{st} + if taskID != "" { + parts = append(parts, "task_id="+taskID) + } + if out != "" { + parts = append(parts, out) + } + p.Footer = strings.Join(parts, " · ") + return p + } + exit := 0 + if v, ok := numericField(res, "exit_code"); ok { + exit = int(v) + } + stdout := pickStringField(res, "stdout") + stderr := pickStringField(res, "stderr") + if stdout != "" { + text := truncLine(firstLine(stdout)) + if text != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "stdout: " + text}) + } + } + if stderr != "" { + text := truncLine(firstLine(stderr)) + if text != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "stderr: " + text}) + } + } + if status == ToolCallStatusFailed { + msg := firstLine(stderr) + if msg == "" { + msg = pickStringField(res, "error", "message") + } + if msg == "" { + msg = fmt.Sprintf("exit=%d", exit) + } + p.Footer = "error: " + truncateSummary(msg) + return p + } + p.Footer = fmt.Sprintf("exit=%d", exit) + return p +} + +func formatBgStatus(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + action := pickStringField(in, "action") + taskID := pickStringField(in, "task_id") + header := action + if taskID != "" { + header = fmt.Sprintf("%s · %s", action, taskID) + } + p := ToolCallPresentation{Header: header} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + tasks := asSliceOfMaps(res["tasks"]) + if len(tasks) > 0 { + p.Header = fmt.Sprintf("%s · %d tasks", action, len(tasks)) + preview := 5 + for i, t := range tasks { + if i >= preview { + break + } + id := pickStringField(t, "task_id", "id") + desc := pickStringField(t, "description") + st := pickStringField(t, "status") + exit := "" + if v, ok := numericField(t, "exit_code"); ok { + exit = fmt.Sprintf(" exit=%d", int(v)) + } + label := id + if desc != "" { + label = fmt.Sprintf("%s \"%s\"", id, desc) + } + text := fmt.Sprintf("- %s · %s%s", label, st, exit) + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: text}) + } + if len(tasks) > preview { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("…and %d more", len(tasks)-preview)}) + } + return p + } + if msg := pickStringField(res, "message"); msg != "" { + p.Footer = msg + } + return p +} + +// --- network tools ------------------------------------------------------ + +func formatWebSearch(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + query := pickStringField(in, "query") + p := ToolCallPresentation{Header: fmt.Sprintf("%q", query)} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + results := asSliceOfMaps(res["results"]) + p.Header = fmt.Sprintf("%d results for %q", len(results), query) + preview := 5 + for i, r := range results { + if i >= preview { + break + } + p.Body = append(p.Body, ToolCallBlock{ + Type: ToolCallBlockLink, + Title: pickStringField(r, "title", "name"), + URL: pickStringField(r, "url", "link"), + Desc: truncLine(pickStringField(r, "description", "snippet", "content", "text")), + }) + } + if len(results) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(results)-preview) + } + return p +} + +func formatWebFetch(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + url := pickStringField(in, "url") + p := ToolCallPresentation{Header: url} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + fetched := pickStringField(res, "url") + if fetched != "" { + url = fetched + } + title := pickStringField(res, "title") + if title != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockLink, Title: title, URL: url}) + } else if url != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockLink, URL: url}) + } + format := pickStringField(res, "format") + length := 0 + if v, ok := numericField(res, "length"); ok { + length = int(v) + } + footer := format + if length > 0 { + footer = fmt.Sprintf("%s · %d chars", footer, length) + } + p.Footer = strings.TrimSpace(footer) + return p +} + +// --- memory / history -------------------------------------------------- + +func formatSearchMemory(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + query := pickStringField(in, "query") + p := ToolCallPresentation{Header: fmt.Sprintf("%q", query)} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + results := asSliceOfMaps(res["results"]) + if len(results) == 0 { + results = asSliceOfMaps(res["items"]) + } + total := len(results) + if v, ok := numericField(res, "total"); ok && int(v) > total { + total = int(v) + } + p.Header = fmt.Sprintf("%d / %d results for %q", len(results), total, query) + preview := 5 + for i, r := range results { + if i >= preview { + break + } + text := pickStringField(r, "text", "content", "memory") + score := "" + if v, ok := numericField(r, "score"); ok { + score = fmt.Sprintf(" (%.2f)", v) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "- " + truncLine(text) + score}) + } + if len(results) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(results)-preview) + } + return p +} + +func formatSearchMessages(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + keyword := pickStringField(in, "keyword", "query", "q") + header := "" + if keyword != "" { + header = fmt.Sprintf("keyword=%q", keyword) + } + p := ToolCallPresentation{Header: header} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + msgs := asSliceOfMaps(res["messages"]) + summary := fmt.Sprintf("%d messages", len(msgs)) + if keyword != "" { + summary = fmt.Sprintf("%s · keyword=%q", summary, keyword) + } + p.Header = summary + preview := 3 + for i, m := range msgs { + if i >= preview { + break + } + role := pickStringField(m, "role", "sender") + text := pickStringField(m, "text", "content") + when := pickStringField(m, "created_at", "timestamp") + label := role + if label == "" { + label = "msg" + } + prefix := label + ": " + if when != "" { + prefix = fmt.Sprintf("[%s] %s: ", when, label) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: prefix + truncLine(text)}) + } + if len(msgs) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(msgs)-preview) + } + return p +} + +func formatListSessions(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + sessions := asSliceOfMaps(res["sessions"]) + p.Header = fmt.Sprintf("%d sessions", len(sessions)) + preview := 5 + for i, s := range sessions { + if i >= preview { + break + } + id := pickStringField(s, "session_id", "id") + title := pickStringField(s, "title", "conversation_name") + platform := pickStringField(s, "platform") + last := pickStringField(s, "last_active") + parts := []string{fmt.Sprintf("- #%s", id)} + if title != "" { + parts = append(parts, fmt.Sprintf("\"%s\"", title)) + } + if platform != "" { + parts = append(parts, "· "+platform) + } + if last != "" { + parts = append(parts, "· "+last) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")}) + } + if len(sessions) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(sessions)-preview) + } + return p +} + +// --- schedule ---------------------------------------------------------- + +func formatListSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + items := asSliceOfMaps(res["items"]) + p.Header = fmt.Sprintf("%d schedules", len(items)) + preview := 5 + for i, item := range items { + if i >= preview { + break + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "- " + summarizeScheduleEntry(item)}) + } + if len(items) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(items)-preview) + } + return p +} + +func summarizeScheduleEntry(item map[string]any) string { + id := pickStringField(item, "id") + name := pickStringField(item, "name") + pattern := pickStringField(item, "pattern") + enabled := true + if b, ok := item["enabled"].(bool); ok { + enabled = b + } + parts := []string{} + if id != "" { + parts = append(parts, fmt.Sprintf("[%s]", id)) + } + if name != "" { + parts = append(parts, fmt.Sprintf("\"%s\"", name)) + } + if pattern != "" { + parts = append(parts, "· "+pattern) + } + state := "enabled" + if !enabled { + state = "disabled" + } + parts = append(parts, "· "+state) + return strings.Join(parts, " ") +} + +func formatGetSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + id := pickStringField(in, "id") + p := ToolCallPresentation{Header: id} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + if res := resultMap(tc); res != nil { + p.Header = summarizeScheduleEntry(res) + } + return p +} + +func formatCreateSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + name := pickStringField(in, "name") + pattern := pickStringField(in, "pattern") + p := ToolCallPresentation{Header: fmt.Sprintf("\"%s\" · cron %s", name, pattern)} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + if res := resultMap(tc); res != nil { + id := pickStringField(res, "id") + if id != "" { + p.Header = fmt.Sprintf("Created [%s] \"%s\" · cron %s", id, name, pattern) + } + } + return p +} + +func formatUpdateSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + id := pickStringField(in, "id") + pattern := pickStringField(in, "pattern") + header := fmt.Sprintf("[%s]", id) + if pattern != "" { + header += " · pattern " + pattern + } + p := ToolCallPresentation{Header: header} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + p.Header = "Updated " + header + return p +} + +func formatDeleteSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + id := pickStringField(in, "id") + p := ToolCallPresentation{Header: fmt.Sprintf("[%s]", id)} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + p.Header = fmt.Sprintf("Deleted [%s]", id) + return p +} + +// --- messaging --------------------------------------------------------- + +func formatSend(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + target := pickStringField(in, "target", "to", "recipient", "chat_id") + platform := pickStringField(in, "platform") + body := pickStringField(in, "body", "content", "message", "text") + header := "" + if target != "" { + header = "→ " + target + if platform != "" { + header += " (" + platform + ")" + } + } + p := ToolCallPresentation{Header: header} + if body != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("%q", truncLine(body))}) + } + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res != nil { + parts := []string{} + if delivered := pickStringField(res, "delivered"); delivered != "" { + parts = append(parts, delivered) + } else { + parts = append(parts, "delivered") + } + if msgID := pickStringField(res, "message_id"); msgID != "" { + parts = append(parts, "message_id="+msgID) + } + p.Footer = strings.Join(parts, " · ") + } + return p +} + +func formatReact(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + emoji := pickStringField(in, "emoji") + msgID := pickStringField(in, "message_id") + remove := false + if b, ok := in["remove"].(bool); ok { + remove = b + } + action := "added" + if remove { + action = "removed" + } + p := ToolCallPresentation{Header: fmt.Sprintf("%s %s on %s", action, emoji, msgID)} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + return p +} + +// --- contacts ---------------------------------------------------------- + +func formatGetContacts(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + contacts := asSliceOfMaps(res["contacts"]) + if contacts == nil { + contacts = asSliceOfMaps(res["items"]) + } + p.Header = fmt.Sprintf("%d contacts", len(contacts)) + preview := 5 + for i, c := range contacts { + if i >= preview { + break + } + name := pickStringField(c, "display_name", "name") + platform := pickStringField(c, "platform") + handle := pickStringField(c, "username", "handle", "channel_id") + last := pickStringField(c, "last_active") + parts := []string{"- " + name} + if platform != "" { + parts = append(parts, "· "+platform) + } + if handle != "" { + parts = append(parts, "· "+handle) + } else if last != "" { + parts = append(parts, "· "+last) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")}) + } + if len(contacts) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(contacts)-preview) + } + return p +} + +// --- email ------------------------------------------------------------- + +func formatListEmailAccounts(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + accounts := asSliceOfMaps(res["accounts"]) + p.Header = fmt.Sprintf("%d accounts", len(accounts)) + preview := 5 + for i, a := range accounts { + if i >= preview { + break + } + addr := pickStringField(a, "address", "email") + perms := pickStringField(a, "permissions") + text := "- " + addr + if perms != "" { + text += " · " + perms + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: text}) + } + if len(accounts) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(accounts)-preview) + } + return p +} + +func formatSendEmail(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + to := pickStringField(in, "to", "recipient", "target") + subject := pickStringField(in, "subject") + header := "" + if to != "" { + header = "→ " + to + } + p := ToolCallPresentation{Header: header} + if subject != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "Subject: " + truncLine(subject)}) + } + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res != nil { + parts := []string{} + if st := pickStringField(res, "status"); st != "" { + parts = append(parts, "status="+st) + } + if msgID := pickStringField(res, "message_id"); msgID != "" { + parts = append(parts, "message_id="+msgID) + } + p.Footer = strings.Join(parts, " · ") + } + return p +} + +func formatListEmail(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + emails := asSliceOfMaps(res["emails"]) + total := 0 + if v, ok := numericField(res, "total"); ok { + total = int(v) + } + page := 0 + if v, ok := numericField(res, "page"); ok { + page = int(v) + } + if total > 0 { + p.Header = fmt.Sprintf("page %d · %d of %d", page, len(emails), total) + } else { + p.Header = fmt.Sprintf("%d emails", len(emails)) + } + preview := 5 + for i, em := range emails { + if i >= preview { + break + } + uid := pickStringField(em, "uid", "id") + from := pickStringField(em, "from", "sender") + subject := pickStringField(em, "subject") + when := pickStringField(em, "date", "received_at", "created_at") + parts := []string{"- #" + uid} + if from != "" { + parts = append(parts, "· "+from) + } + if subject != "" { + parts = append(parts, "· \""+truncLine(subject)+"\"") + } + if when != "" { + parts = append(parts, "· "+when) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")}) + } + if len(emails) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(emails)-preview) + } + return p +} + +func formatReadEmail(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + uid := pickStringField(in, "uid", "id", "message_id") + p := ToolCallPresentation{Header: "#" + uid} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + from := pickStringField(res, "from", "sender") + subject := pickStringField(res, "subject") + received := pickStringField(res, "date", "received_at", "received") + if from != "" { + p.Header = "From: " + from + } + if subject != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "Subject: " + truncLine(subject)}) + } + if received != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "Received: " + received}) + } + p.Footer = "(body hidden)" + return p +} + +// --- browser ----------------------------------------------------------- + +func formatBrowserAction(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + action := pickStringField(in, "action") + url := pickStringField(in, "url") + header := action + if url != "" { + header = fmt.Sprintf("%s · %s", action, url) + } + p := ToolCallPresentation{Header: header} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + return p +} + +func formatBrowserObserve(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + kind := pickStringField(in, "type", "kind") + p := ToolCallPresentation{Header: kind} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + tabs := asSliceOfMaps(res["tabs"]) + if len(tabs) > 0 { + p.Header = fmt.Sprintf("%s · %d tabs", kind, len(tabs)) + preview := 5 + for i, t := range tabs { + if i >= preview { + break + } + title := pickStringField(t, "title") + u := pickStringField(t, "url") + active := false + if b, ok := t["active"].(bool); ok { + active = b + } + prefix := "- " + if active { + prefix = "- [active] " + } + parts := []string{prefix + title} + if u != "" { + parts = append(parts, "— "+u) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")}) + } + } + return p +} + +func formatBrowserRemoteSession(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + action := pickStringField(in, "action") + p := ToolCallPresentation{Header: action} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res != nil { + id := pickStringField(res, "session_id") + expires := pickStringField(res, "expires", "expires_in", "ttl") + parts := []string{action} + if id != "" { + parts = append(parts, "session_id="+id) + } + if expires != "" { + parts = append(parts, "expires "+expires) + } + p.Header = strings.Join(parts, " · ") + } + return p +} + +// --- subagent / skills / media ----------------------------------------- + +func formatSpawn(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + in := inputMap(tc) + tasks := asSliceOfMaps(in["tasks"]) + if len(tasks) > 0 { + p.Header = fmt.Sprintf("%d subagent tasks", len(tasks)) + } + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + results := asSliceOfMaps(res["results"]) + succeeded := 0 + for _, r := range results { + if b, ok := r["success"].(bool); ok && b { + succeeded++ + continue + } + if b, ok := r["ok"].(bool); ok && b { + succeeded++ + } + } + p.Header = fmt.Sprintf("%d / %d subagent tasks succeeded", succeeded, len(results)) + preview := 5 + for i, r := range results { + if i >= preview { + break + } + task := pickStringField(r, "task", "description") + sess := pickStringField(r, "session_id") + parts := []string{fmt.Sprintf("- \"%s\"", task)} + if sess != "" { + parts = append(parts, "· session "+sess) + } + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")}) + } + if len(results) > preview { + p.Footer = fmt.Sprintf("…and %d more", len(results)-preview) + } + return p +} + +func formatUseSkill(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + name := pickStringField(in, "name", "skill", "skill_name") + p := ToolCallPresentation{Header: name} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res != nil { + desc := pickStringField(res, "description", "summary") + if desc != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: truncLine(desc)}) + } + } + return p +} + +func formatGenerateImage(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + prompt := pickStringField(in, "prompt", "description") + p := ToolCallPresentation{Header: fmt.Sprintf("prompt: %q", truncLine(prompt))} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + path := pickStringField(res, "path", "file", "url") + mime := pickStringField(res, "mime", "content_type") + size := "" + if v, ok := numericField(res, "size"); ok { + size = humanSize(int64(v)) + } + headerParts := []string{} + if path != "" { + headerParts = append(headerParts, path) + } + if size != "" { + headerParts = append(headerParts, size) + } + if mime != "" { + headerParts = append(headerParts, mime) + } + if len(headerParts) > 0 { + p.Header = strings.Join(headerParts, " · ") + } + if prompt != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "prompt: " + fmt.Sprintf("%q", truncLine(prompt))}) + } + return p +} + +func formatSpeak(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + in := inputMap(tc) + text := pickStringField(in, "text", "content") + p := ToolCallPresentation{Header: fmt.Sprintf("%q", truncLine(text))} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + p.Header = "delivered · " + fmt.Sprintf("%q", truncLine(text)) + return p +} + +func formatTranscribeAudio(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation { + p := ToolCallPresentation{} + if status == ToolCallStatusRunning { + return p + } + if e, done := errorPresentation(p, status, tc); done { + return e + } + res := resultMap(tc) + if res == nil { + return p + } + lang := pickStringField(res, "language", "lang") + durSec := 0 + if v, ok := numericField(res, "duration"); ok { + durSec = int(v) + } else if v, ok := numericField(res, "duration_seconds"); ok { + durSec = int(v) + } + text := pickStringField(res, "text", "transcription") + headerParts := []string{} + if lang != "" { + headerParts = append(headerParts, lang) + } + if durSec > 0 { + headerParts = append(headerParts, fmt.Sprintf("%ds", durSec)) + } + if len(headerParts) > 0 { + p.Header = strings.Join(headerParts, " · ") + } + if text != "" { + p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("%q", truncLine(text))}) + } + return p +} + +// humanSize formats a byte count into a short, human-readable string. +func humanSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + units := []string{"KB", "MB", "GB", "TB"} + if exp >= len(units) { + exp = len(units) - 1 + } + return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp]) +} diff --git a/internal/channel/toolcall_formatters_test.go b/internal/channel/toolcall_formatters_test.go new file mode 100644 index 00000000..77e39386 --- /dev/null +++ b/internal/channel/toolcall_formatters_test.go @@ -0,0 +1,358 @@ +package channel + +import ( + "strings" + "testing" +) + +func hasTextBlock(body []ToolCallBlock, needle string) bool { + for _, b := range body { + if b.Type != ToolCallBlockText { + continue + } + if strings.Contains(b.Text, needle) { + return true + } + } + return false +} + +func hasLinkBlock(body []ToolCallBlock, url string) *ToolCallBlock { + for i := range body { + if body[i].Type != ToolCallBlockLink { + continue + } + if body[i].URL == url { + return &body[i] + } + } + return nil +} + +func TestFormatListIncludesEntries(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "list", + Input: map[string]any{"path": "/var/log"}, + Result: map[string]any{ + "total_count": float64(12), + "entries": []any{ + map[string]any{"path": "syslog", "is_dir": false, "size": float64(2300)}, + map[string]any{"path": "auth.log", "is_dir": false, "size": float64(1100000)}, + map[string]any{"path": "nginx", "is_dir": true}, + }, + }, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Header, "/var/log") || !strings.Contains(p.Header, "12 entries") { + t.Fatalf("unexpected header: %q", p.Header) + } + if !hasTextBlock(p.Body, "syslog") { + t.Fatalf("expected syslog entry in body, got %+v", p.Body) + } + if !hasTextBlock(p.Body, "nginx/") { + t.Fatalf("expected nginx/ directory entry in body, got %+v", p.Body) + } + if !hasTextBlock(p.Body, "…and 9 more") { + t.Fatalf("expected ellipsis footer for remaining items, got %+v", p.Body) + } + if p.InputSummary != "" || p.ResultSummary != "" { + t.Fatalf("formatter output must not leak InputSummary/ResultSummary raw JSON, got in=%q res=%q", p.InputSummary, p.ResultSummary) + } + rendered := RenderToolCallMessage(p) + if strings.Contains(rendered, "\"entries\"") || strings.Contains(rendered, "{\"") { + t.Fatalf("rendered output leaked raw JSON result:\n%s", rendered) + } +} + +func TestFormatExecForeground(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "exec", + Input: map[string]any{"command": "ls -la /tmp"}, + Result: map[string]any{"exit_code": float64(0), "stdout": "total 16\nfoo bar"}, + } + p := BuildToolCallEnd(tc) + if !strings.HasPrefix(p.Header, "$ ls -la /tmp") { + t.Fatalf("unexpected header: %q", p.Header) + } + if p.Footer != "exit=0" { + t.Fatalf("unexpected footer: %q", p.Footer) + } + if !hasTextBlock(p.Body, "stdout: total 16") { + t.Fatalf("expected stdout block, got %+v", p.Body) + } +} + +func TestFormatExecBackground(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "exec", + Input: map[string]any{"command": "long_running.sh"}, + Result: map[string]any{ + "status": "background_started", + "task_id": "bg_abc", + "output_file": "/tmp/bg_abc.log", + }, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Footer, "background_started") || + !strings.Contains(p.Footer, "task_id=bg_abc") || + !strings.Contains(p.Footer, "/tmp/bg_abc.log") { + t.Fatalf("unexpected footer: %q", p.Footer) + } +} + +func TestFormatWebSearchEmitsLinks(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "web_search", + Input: map[string]any{"query": "golang generics tutorial"}, + Result: map[string]any{ + "results": []any{ + map[string]any{ + "title": "Tutorial: Getting started with generics", + "url": "https://go.dev/doc/tutorial/generics", + "description": "A comprehensive walkthrough", + }, + map[string]any{ + "title": "Go 1.18 is released", + "url": "https://go.dev/blog/go1.18", + }, + }, + }, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Header, "2 results") || !strings.Contains(p.Header, `"golang generics tutorial"`) { + t.Fatalf("unexpected header: %q", p.Header) + } + link := hasLinkBlock(p.Body, "https://go.dev/doc/tutorial/generics") + if link == nil { + t.Fatalf("expected link block for tutorial, got %+v", p.Body) + } + if link.Title != "Tutorial: Getting started with generics" { + t.Fatalf("unexpected title: %q", link.Title) + } + if link.Desc != "A comprehensive walkthrough" { + t.Fatalf("unexpected desc: %q", link.Desc) + } +} + +func TestFormatSendCarriesTargetAndBody(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "send", + Input: map[string]any{ + "target": "chat:123", + "platform": "telegram", + "body": "Hello there", + }, + Result: map[string]any{"delivered": "delivered", "message_id": "msg_456"}, + } + p := BuildToolCallEnd(tc) + if p.Header != "→ chat:123 (telegram)" { + t.Fatalf("unexpected header: %q", p.Header) + } + if !hasTextBlock(p.Body, "Hello there") { + t.Fatalf("expected body text, got %+v", p.Body) + } + if !strings.Contains(p.Footer, "message_id=msg_456") { + t.Fatalf("unexpected footer: %q", p.Footer) + } +} + +func TestFormatSendEmailCarriesSubject(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "send_email", + Input: map[string]any{ + "to": "alice@example.com", + "subject": "Meeting notes", + }, + Result: map[string]any{"status": "sent", "message_id": "mid1"}, + } + p := BuildToolCallEnd(tc) + if p.Header != "→ alice@example.com" { + t.Fatalf("unexpected header: %q", p.Header) + } + if !hasTextBlock(p.Body, "Subject: Meeting notes") { + t.Fatalf("expected subject block, got %+v", p.Body) + } + if !strings.Contains(p.Footer, "status=sent") || !strings.Contains(p.Footer, "message_id=mid1") { + t.Fatalf("unexpected footer: %q", p.Footer) + } +} + +func TestFormatSearchMemoryPrintsScoreAndTotal(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "search_memory", + Input: map[string]any{"query": "previous trip to Tokyo"}, + Result: map[string]any{ + "total": float64(10), + "results": []any{ + map[string]any{"text": "Alice prefers sushi over ramen", "score": float64(0.91)}, + map[string]any{"text": "Bob mentioned a trip to Tokyo in March", "score": float64(0.87)}, + }, + }, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Header, "2 / 10 results") { + t.Fatalf("unexpected header: %q", p.Header) + } + if !hasTextBlock(p.Body, "(0.91)") { + t.Fatalf("expected score annotation, got %+v", p.Body) + } +} + +func TestFormatSearchMessagesTruncatesPreview(t *testing.T) { + t.Parallel() + + msgs := make([]any, 0, 6) + for i := 0; i < 6; i++ { + msgs = append(msgs, map[string]any{ + "role": "user", + "text": "I need a kanban board", + "created_at": "2026-04-22 10:00", + }) + } + tc := &StreamToolCall{ + Name: "search_messages", + Input: map[string]any{"keyword": "kanban"}, + Result: map[string]any{"messages": msgs}, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Header, `keyword="kanban"`) || !strings.Contains(p.Header, "6 messages") { + t.Fatalf("unexpected header: %q", p.Header) + } + if !strings.Contains(p.Footer, "…and 3 more") { + t.Fatalf("expected ellipsis footer, got %q", p.Footer) + } +} + +func TestFormatSpawnSuccessRatio(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "spawn", + Result: map[string]any{ + "results": []any{ + map[string]any{"success": true, "task": "analyze repo structure", "session_id": "sess_1"}, + map[string]any{"success": true, "task": "summarize README", "session_id": "sess_2"}, + }, + }, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Header, "2 / 2") { + t.Fatalf("unexpected header: %q", p.Header) + } + if !hasTextBlock(p.Body, "analyze repo structure") { + t.Fatalf("expected first task in body, got %+v", p.Body) + } +} + +func TestFormatWebFetchShowsLink(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "web_fetch", + Input: map[string]any{"url": "https://example.com/article"}, + Result: map[string]any{ + "title": "Example Article Title", + "format": "markdown", + "length": float64(3421), + }, + } + p := BuildToolCallEnd(tc) + link := hasLinkBlock(p.Body, "https://example.com/article") + if link == nil { + t.Fatalf("expected link block, got %+v", p.Body) + } + if link.Title != "Example Article Title" { + t.Fatalf("unexpected link title: %q", link.Title) + } + if !strings.Contains(p.Footer, "markdown") || !strings.Contains(p.Footer, "3421 chars") { + t.Fatalf("unexpected footer: %q", p.Footer) + } +} + +func TestFormatCreateScheduleSuccess(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "create_schedule", + Input: map[string]any{ + "name": "Daily report", + "pattern": "0 9 * * *", + }, + Result: map[string]any{"id": "sch_42"}, + } + p := BuildToolCallEnd(tc) + if !strings.Contains(p.Header, "[sch_42]") || !strings.Contains(p.Header, "Daily report") { + t.Fatalf("unexpected header: %q", p.Header) + } +} + +func TestFormatFailureEmitsErrorFooter(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "read", + Input: map[string]any{"path": "/etc/shadow"}, + Result: map[string]any{"error": "permission denied"}, + } + p := BuildToolCallEnd(tc) + if p.Status != ToolCallStatusFailed { + t.Fatalf("expected failed status, got %q", p.Status) + } + if !strings.Contains(p.Footer, "permission denied") { + t.Fatalf("expected error footer, got %q", p.Footer) + } +} + +func TestFormatExecFailedExitCode(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "exec", + Input: map[string]any{"command": "false"}, + Result: map[string]any{ + "exit_code": float64(2), + "stderr": "boom", + }, + } + p := BuildToolCallEnd(tc) + if p.Status != ToolCallStatusFailed { + t.Fatalf("expected failed status, got %q", p.Status) + } + if !strings.Contains(p.Footer, "error") || !strings.Contains(p.Footer, "boom") { + t.Fatalf("expected stderr-based error footer, got %q", p.Footer) + } +} + +func TestExternalToolFallsBackToGenericSummary(t *testing.T) { + t.Parallel() + + tc := &StreamToolCall{ + Name: "mcp.custom.do_thing", + Input: map[string]any{"foo": "bar"}, + Result: map[string]any{"ok": true}, + } + p := BuildToolCallEnd(tc) + if p.Emoji != ExternalToolCallEmoji { + t.Fatalf("expected external emoji, got %q", p.Emoji) + } + if p.Status != ToolCallStatusCompleted { + t.Fatalf("expected completed status, got %q", p.Status) + } + if p.InputSummary == "" { + t.Fatalf("expected generic input summary to be populated") + } +} diff --git a/internal/channel/toolcall_summary.go b/internal/channel/toolcall_summary.go new file mode 100644 index 00000000..6745f67c --- /dev/null +++ b/internal/channel/toolcall_summary.go @@ -0,0 +1,302 @@ +package channel + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/memohai/memoh/internal/textutil" +) + +const ( + toolCallSummaryMaxRunes = 200 + toolCallSummaryTruncMark = "…" +) + +// SummarizeToolInput returns a short human-readable representation of a +// tool call's input payload, prioritizing known key fields (path, command, +// query, url, target, to, id, cron, action) before falling back to a compact +// JSON projection. +func SummarizeToolInput(_ string, input any) string { + if input == nil { + return "" + } + m, ok := normalizeToMap(input) + if ok { + if s := pickStringField(m, "path", "file_path", "filepath"); s != "" { + return truncateSummary(s) + } + if s := pickStringField(m, "command", "cmd"); s != "" { + return truncateSummary(firstLine(s)) + } + if s := pickStringField(m, "query"); s != "" { + return truncateSummary(s) + } + if s := pickStringField(m, "url"); s != "" { + return truncateSummary(s) + } + if s := combineTargetAndBody(m); s != "" { + return truncateSummary(s) + } + if s := pickStringField(m, "id"); s != "" { + if cron := strings.TrimSpace(fmt.Sprint(m["cron"])); cron != "" && cron != "" { + return truncateSummary(fmt.Sprintf("%s · %s", s, cron)) + } + if action := strings.TrimSpace(fmt.Sprint(m["action"])); action != "" && action != "" { + return truncateSummary(fmt.Sprintf("%s · %s", s, action)) + } + return truncateSummary(s) + } + if s := pickStringField(m, "cron"); s != "" { + return truncateSummary(s) + } + if s := pickStringField(m, "action"); s != "" { + return truncateSummary(s) + } + } + return compactJSONSummary(input) +} + +// SummarizeToolResult returns a short representation of a tool call's result, +// surfacing status/error/count signals when present and otherwise falling +// back to trimmed text or a compact JSON projection. +func SummarizeToolResult(_ string, result any) string { + if result == nil { + return "" + } + if s, ok := result.(string); ok { + return truncateSummary(strings.TrimSpace(s)) + } + m, ok := normalizeToMap(result) + if ok { + parts := make([]string, 0, 4) + if errStr := pickStringField(m, "error"); errStr != "" { + return truncateSummary("error: " + errStr) + } + if okVal, okFound := m["ok"]; okFound { + parts = append(parts, fmt.Sprintf("ok=%v", okVal)) + } + if status := pickStringField(m, "status"); status != "" { + parts = append(parts, "status="+status) + } + if code, ok := numericField(m, "exit_code"); ok { + parts = append(parts, fmt.Sprintf("exit=%v", code)) + } + if count, ok := numericField(m, "count"); ok { + parts = append(parts, fmt.Sprintf("count=%v", count)) + } + if msg := pickStringField(m, "message"); msg != "" { + parts = append(parts, msg) + } + if stdout := pickStringField(m, "stdout"); stdout != "" { + parts = append(parts, "stdout: "+firstLine(stdout)) + } else if stderr := pickStringField(m, "stderr"); stderr != "" { + parts = append(parts, "stderr: "+firstLine(stderr)) + } + if len(parts) > 0 { + return truncateSummary(strings.Join(parts, " · ")) + } + } + return compactJSONSummary(result) +} + +// isToolResultFailure inspects a tool result payload and reports whether it +// represents a failure (ok=false, non-empty error, non-zero exit_code). +func isToolResultFailure(result any) bool { + if result == nil { + return false + } + m, ok := normalizeToMap(result) + if !ok { + return false + } + if errStr := pickStringField(m, "error"); errStr != "" { + return true + } + if okVal, okFound := m["ok"]; okFound { + if b, ok := okVal.(bool); ok && !b { + return true + } + } + if code, ok := numericField(m, "exit_code"); ok { + if code != 0 { + return true + } + } + return false +} + +func normalizeToMap(v any) (map[string]any, bool) { + switch val := v.(type) { + case map[string]any: + return val, true + case json.RawMessage: + if len(val) == 0 { + return nil, false + } + var m map[string]any + if err := json.Unmarshal(val, &m); err == nil { + return m, true + } + case []byte: + if len(val) == 0 { + return nil, false + } + var m map[string]any + if err := json.Unmarshal(val, &m); err == nil { + return m, true + } + } + return nil, false +} + +func pickStringField(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch val := v.(type) { + case string: + if s := strings.TrimSpace(val); s != "" { + return s + } + case fmt.Stringer: + if s := strings.TrimSpace(val.String()); s != "" { + return s + } + } + } + } + return "" +} + +func numericField(m map[string]any, key string) (float64, bool) { + v, ok := m[key] + if !ok { + return 0, false + } + switch val := v.(type) { + case float64: + return val, true + case int: + return float64(val), true + case int64: + return float64(val), true + case json.Number: + if f, err := val.Float64(); err == nil { + return f, true + } + } + return 0, false +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if idx := strings.IndexByte(s, '\n'); idx >= 0 { + return strings.TrimSpace(s[:idx]) + } + return s +} + +func combineTargetAndBody(m map[string]any) string { + target := pickStringField(m, "target", "to", "recipient") + body := pickStringField(m, "body", "content", "message", "text", "subject") + if target != "" && body != "" { + return fmt.Sprintf("→ %s: %s", target, body) + } + if target != "" { + return "→ " + target + } + return "" +} + +func truncateSummary(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + return textutil.TruncateRunesWithSuffix(s, toolCallSummaryMaxRunes, toolCallSummaryTruncMark) +} + +// compactJSONSummary is a last-resort projection for values where we cannot +// extract known key fields. It omits binary / base64 content and large arrays. +func compactJSONSummary(v any) string { + if v == nil { + return "" + } + if raw, ok := v.(json.RawMessage); ok { + var decoded any + if err := json.Unmarshal(raw, &decoded); err == nil { + v = decoded + } else { + return truncateSummary(string(raw)) + } + } + projected := projectForSummary(v) + bytes, err := json.Marshal(projected) + if err != nil { + return truncateSummary(fmt.Sprint(v)) + } + return truncateSummary(string(bytes)) +} + +// projectForSummary reduces large / binary values before serialization so +// the summary stays short. It replaces base64-looking strings, truncates +// slices, and sorts map keys for stability. +func projectForSummary(v any) any { + switch val := v.(type) { + case map[string]any: + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + out := make(map[string]any, len(keys)) + for _, k := range keys { + out[k] = projectForSummary(val[k]) + } + return out + case []any: + if len(val) == 0 { + return val + } + preview := 3 + if len(val) < preview { + preview = len(val) + } + head := make([]any, 0, preview) + for i := 0; i < preview; i++ { + head = append(head, projectForSummary(val[i])) + } + if len(val) > preview { + return map[string]any{ + "count": len(val), + "preview": head, + } + } + return head + case string: + if isLikelyBase64(val) { + return fmt.Sprintf("", len(val)) + } + if len(val) > 120 { + return textutil.TruncateRunesWithSuffix(val, 120, toolCallSummaryTruncMark) + } + return val + default: + return val + } +} + +func isLikelyBase64(s string) bool { + if len(s) < 200 { + return false + } + if strings.ContainsAny(s, " \n\t") { + return false + } + if _, err := base64.StdEncoding.DecodeString(s); err == nil { + return true + } + return false +} diff --git a/internal/channel/toolcall_summary_test.go b/internal/channel/toolcall_summary_test.go new file mode 100644 index 00000000..f273c3dd --- /dev/null +++ b/internal/channel/toolcall_summary_test.go @@ -0,0 +1,147 @@ +package channel + +import ( + "strings" + "testing" +) + +func TestSummarizeToolInputFileField(t *testing.T) { + t.Parallel() + + got := SummarizeToolInput("read", map[string]any{"path": "/var/log/syslog"}) + if got != "/var/log/syslog" { + t.Fatalf("unexpected: %q", got) + } +} + +func TestSummarizeToolInputCommandFirstLine(t *testing.T) { + t.Parallel() + + got := SummarizeToolInput("exec", map[string]any{"command": "echo hi\nsleep 10"}) + if got != "echo hi" { + t.Fatalf("unexpected first line: %q", got) + } +} + +func TestSummarizeToolInputMessageTargetAndBody(t *testing.T) { + t.Parallel() + + got := SummarizeToolInput("send", map[string]any{ + "target": "chat:123", + "body": "Hello there", + }) + if !strings.Contains(got, "chat:123") || !strings.Contains(got, "Hello there") { + t.Fatalf("unexpected target/body summary: %q", got) + } +} + +func TestSummarizeToolInputScheduleID(t *testing.T) { + t.Parallel() + + got := SummarizeToolInput("update_schedule", map[string]any{ + "id": "sch_42", + "cron": "0 9 * * *", + }) + if got != "sch_42 · 0 9 * * *" { + t.Fatalf("unexpected schedule summary: %q", got) + } +} + +func TestSummarizeToolInputTruncatesLongValues(t *testing.T) { + t.Parallel() + + long := strings.Repeat("x", 400) + got := SummarizeToolInput("web_fetch", map[string]any{"url": long}) + if !strings.HasSuffix(got, "…") { + t.Fatalf("expected truncation suffix, got %q", got) + } + if len([]rune(got)) > 201 { + t.Fatalf("summary not truncated: rune len=%d", len([]rune(got))) + } +} + +func TestSummarizeToolInputFallbackCompactJSON(t *testing.T) { + t.Parallel() + + got := SummarizeToolInput("unknown", map[string]any{"alpha": 1, "beta": 2}) + if !strings.Contains(got, "\"alpha\"") || !strings.Contains(got, "\"beta\"") { + t.Fatalf("expected JSON fallback: %q", got) + } +} + +func TestSummarizeToolResultPrefersError(t *testing.T) { + t.Parallel() + + got := SummarizeToolResult("read", map[string]any{"error": "ENOENT", "ok": false}) + if !strings.HasPrefix(got, "error: ENOENT") { + t.Fatalf("unexpected result: %q", got) + } +} + +func TestSummarizeToolResultCombinesSignals(t *testing.T) { + t.Parallel() + + got := SummarizeToolResult("exec", map[string]any{ + "ok": true, + "exit_code": 0, + "stdout": "line1\nline2", + }) + if !strings.Contains(got, "ok=true") { + t.Fatalf("missing ok signal: %q", got) + } + if !strings.Contains(got, "exit=0") { + t.Fatalf("missing exit_code: %q", got) + } + if !strings.Contains(got, "stdout: line1") { + t.Fatalf("missing stdout first line: %q", got) + } +} + +func TestSummarizeToolResultPlainString(t *testing.T) { + t.Parallel() + + got := SummarizeToolResult("read", "hello world") + if got != "hello world" { + t.Fatalf("unexpected plain result: %q", got) + } +} + +func TestSummarizeToolResultLargeJSONFallback(t *testing.T) { + t.Parallel() + + items := make([]any, 0, 10) + for i := 0; i < 10; i++ { + items = append(items, map[string]any{"id": i}) + } + got := SummarizeToolResult("list", map[string]any{"items": items}) + if got == "" { + t.Fatalf("expected non-empty summary") + } + if len([]rune(got)) > 201 { + t.Fatalf("expected truncated: %d", len([]rune(got))) + } +} + +func TestIsToolResultFailure(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + result any + want bool + }{ + {"nil", nil, false}, + {"ok_true", map[string]any{"ok": true}, false}, + {"ok_false", map[string]any{"ok": false}, true}, + {"error_present", map[string]any{"error": "bad"}, true}, + {"empty_error", map[string]any{"error": ""}, false}, + {"exit_zero", map[string]any{"exit_code": 0}, false}, + {"exit_nonzero", map[string]any{"exit_code": 2}, true}, + {"plain_string", "hello", false}, + } + for _, tc := range cases { + if got := isToolResultFailure(tc.result); got != tc.want { + t.Fatalf("%s: isToolResultFailure = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/db/sqlc/conversations.sql.go b/internal/db/sqlc/conversations.sql.go index 518d1c4c..6bc65223 100644 --- a/internal/db/sqlc/conversations.sql.go +++ b/internal/db/sqlc/conversations.sql.go @@ -511,7 +511,7 @@ WITH updated AS ( SET display_name = $1, updated_at = now() WHERE bots.id = $2 - RETURNING id, owner_user_id, display_name, avatar_url, timezone, is_active, status, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, compaction_enabled, compaction_threshold, compaction_ratio, compaction_model_id, title_model_id, image_model_id, discuss_probe_model_id, tts_model_id, transcription_model_id, browser_context_id, persist_full_tool_results, metadata, created_at, updated_at, acl_default_effect + RETURNING id, owner_user_id, display_name, avatar_url, timezone, is_active, status, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, compaction_enabled, compaction_threshold, compaction_ratio, compaction_model_id, title_model_id, image_model_id, discuss_probe_model_id, tts_model_id, transcription_model_id, browser_context_id, persist_full_tool_results, show_tool_calls_in_im, metadata, created_at, updated_at, acl_default_effect ) SELECT updated.id AS id, diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 09750f1a..2ebbe28a 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -37,6 +37,7 @@ type Bot struct { TranscriptionModelID pgtype.UUID `json:"transcription_model_id"` BrowserContextID pgtype.UUID `json:"browser_context_id"` PersistFullToolResults bool `json:"persist_full_tool_results"` + ShowToolCallsInIm bool `json:"show_tool_calls_in_im"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` diff --git a/internal/db/sqlc/settings.sql.go b/internal/db/sqlc/settings.sql.go index 8e93176f..16856bb8 100644 --- a/internal/db/sqlc/settings.sql.go +++ b/internal/db/sqlc/settings.sql.go @@ -33,6 +33,7 @@ SET language = 'auto', transcription_model_id = NULL, browser_context_id = NULL, persist_full_tool_results = false, + show_tool_calls_in_im = false, updated_at = now() WHERE id = $1 ` @@ -65,7 +66,8 @@ SELECT tts_models.id AS tts_model_id, transcription_models.id AS transcription_model_id, browser_contexts.id AS browser_context_id, - bots.persist_full_tool_results + bots.persist_full_tool_results, + bots.show_tool_calls_in_im FROM bots LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = bots.heartbeat_model_id @@ -103,6 +105,7 @@ type GetSettingsByBotIDRow struct { TranscriptionModelID pgtype.UUID `json:"transcription_model_id"` BrowserContextID pgtype.UUID `json:"browser_context_id"` PersistFullToolResults bool `json:"persist_full_tool_results"` + ShowToolCallsInIm bool `json:"show_tool_calls_in_im"` } func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSettingsByBotIDRow, error) { @@ -131,6 +134,7 @@ func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSe &i.TranscriptionModelID, &i.BrowserContextID, &i.PersistFullToolResults, + &i.ShowToolCallsInIm, ) return i, err } @@ -159,9 +163,10 @@ WITH updated AS ( transcription_model_id = COALESCE($19::uuid, bots.transcription_model_id), browser_context_id = COALESCE($20::uuid, bots.browser_context_id), persist_full_tool_results = $21, + show_tool_calls_in_im = $22, updated_at = now() - WHERE bots.id = $22 - RETURNING bots.id, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.compaction_ratio, bots.timezone, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.image_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.transcription_model_id, bots.browser_context_id, bots.persist_full_tool_results + WHERE bots.id = $23 + RETURNING bots.id, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.compaction_ratio, bots.timezone, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.image_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.transcription_model_id, bots.browser_context_id, bots.persist_full_tool_results, bots.show_tool_calls_in_im ) SELECT updated.id AS bot_id, @@ -185,7 +190,8 @@ SELECT tts_models.id AS tts_model_id, transcription_models.id AS transcription_model_id, browser_contexts.id AS browser_context_id, - updated.persist_full_tool_results + updated.persist_full_tool_results, + updated.show_tool_calls_in_im FROM updated LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = updated.heartbeat_model_id @@ -221,6 +227,7 @@ type UpsertBotSettingsParams struct { TranscriptionModelID pgtype.UUID `json:"transcription_model_id"` BrowserContextID pgtype.UUID `json:"browser_context_id"` PersistFullToolResults bool `json:"persist_full_tool_results"` + ShowToolCallsInIm bool `json:"show_tool_calls_in_im"` ID pgtype.UUID `json:"id"` } @@ -247,6 +254,7 @@ type UpsertBotSettingsRow struct { TranscriptionModelID pgtype.UUID `json:"transcription_model_id"` BrowserContextID pgtype.UUID `json:"browser_context_id"` PersistFullToolResults bool `json:"persist_full_tool_results"` + ShowToolCallsInIm bool `json:"show_tool_calls_in_im"` } func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsParams) (UpsertBotSettingsRow, error) { @@ -272,6 +280,7 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa arg.TranscriptionModelID, arg.BrowserContextID, arg.PersistFullToolResults, + arg.ShowToolCallsInIm, arg.ID, ) var i UpsertBotSettingsRow @@ -298,6 +307,7 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa &i.TranscriptionModelID, &i.BrowserContextID, &i.PersistFullToolResults, + &i.ShowToolCallsInIm, ) return i, err } diff --git a/internal/settings/service.go b/internal/settings/service.go index 7af9c4ff..c77a0274 100644 --- a/internal/settings/service.go +++ b/internal/settings/service.go @@ -101,6 +101,9 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest if req.PersistFullToolResults != nil { current.PersistFullToolResults = *req.PersistFullToolResults } + if req.ShowToolCallsInIM != nil { + current.ShowToolCallsInIM = *req.ShowToolCallsInIM + } timezoneValue := pgtype.Text{} if req.Timezone != nil { normalized, err := normalizeOptionalTimezone(*req.Timezone) @@ -215,6 +218,7 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest TranscriptionModelID: transcriptionModelUUID, BrowserContextID: browserContextUUID, PersistFullToolResults: current.PersistFullToolResults, + ShowToolCallsInIm: current.ShowToolCallsInIM, }) if err != nil { return Settings{}, err @@ -310,6 +314,7 @@ func normalizeBotSettingsReadRow(row sqlc.GetSettingsByBotIDRow) Settings { row.TranscriptionModelID, row.BrowserContextID, row.PersistFullToolResults, + row.ShowToolCallsInIm, ) } @@ -335,6 +340,7 @@ func normalizeBotSettingsWriteRow(row sqlc.UpsertBotSettingsRow) Settings { row.TranscriptionModelID, row.BrowserContextID, row.PersistFullToolResults, + row.ShowToolCallsInIm, ) } @@ -359,6 +365,7 @@ func normalizeBotSettingsFields( transcriptionModelID pgtype.UUID, browserContextID pgtype.UUID, persistFullToolResults bool, + showToolCallsInIM bool, ) Settings { settings := normalizeBotSetting(language, "", reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold, compactionRatio) if timezone.Valid { @@ -395,6 +402,7 @@ func normalizeBotSettingsFields( settings.BrowserContextID = uuid.UUID(browserContextID.Bytes).String() } settings.PersistFullToolResults = persistFullToolResults + settings.ShowToolCallsInIM = showToolCallsInIM return settings } diff --git a/internal/settings/service_test.go b/internal/settings/service_test.go new file mode 100644 index 00000000..95bdec9d --- /dev/null +++ b/internal/settings/service_test.go @@ -0,0 +1,69 @@ +package settings + +import ( + "testing" + + "github.com/memohai/memoh/internal/db/sqlc" +) + +func TestNormalizeBotSettingsReadRow_ShowToolCallsInIMDefault(t *testing.T) { + t.Parallel() + + row := sqlc.GetSettingsByBotIDRow{ + Language: "en", + ReasoningEnabled: false, + ReasoningEffort: "medium", + HeartbeatEnabled: false, + HeartbeatInterval: 60, + CompactionEnabled: false, + CompactionThreshold: 0, + CompactionRatio: 80, + ShowToolCallsInIm: false, + } + got := normalizeBotSettingsReadRow(row) + if got.ShowToolCallsInIM { + t.Fatalf("expected default ShowToolCallsInIM=false, got true") + } +} + +func TestNormalizeBotSettingsReadRow_ShowToolCallsInIMPropagates(t *testing.T) { + t.Parallel() + + row := sqlc.GetSettingsByBotIDRow{ + Language: "en", + ReasoningEffort: "medium", + HeartbeatInterval: 60, + CompactionRatio: 80, + ShowToolCallsInIm: true, + } + got := normalizeBotSettingsReadRow(row) + if !got.ShowToolCallsInIM { + t.Fatalf("expected ShowToolCallsInIM=true to propagate from row") + } +} + +func TestUpsertRequestShowToolCallsInIM_PointerSemantics(t *testing.T) { + t.Parallel() + + // When the field is nil, the UpsertRequest should not touch the current + // setting. When non-nil, the dereferenced value should win. We exercise + // the small gate block without hitting the database. + current := Settings{ShowToolCallsInIM: true} + + var req UpsertRequest + if req.ShowToolCallsInIM != nil { + current.ShowToolCallsInIM = *req.ShowToolCallsInIM + } + if !current.ShowToolCallsInIM { + t.Fatalf("nil pointer must leave current value unchanged") + } + + off := false + req.ShowToolCallsInIM = &off + if req.ShowToolCallsInIM != nil { + current.ShowToolCallsInIM = *req.ShowToolCallsInIM + } + if current.ShowToolCallsInIM { + t.Fatalf("explicit false pointer must clear the flag") + } +} diff --git a/internal/settings/types.go b/internal/settings/types.go index df802c3e..a59969ec 100644 --- a/internal/settings/types.go +++ b/internal/settings/types.go @@ -29,6 +29,7 @@ type Settings struct { CompactionModelID string `json:"compaction_model_id,omitempty"` DiscussProbeModelID string `json:"discuss_probe_model_id,omitempty"` PersistFullToolResults bool `json:"persist_full_tool_results"` + ShowToolCallsInIM bool `json:"show_tool_calls_in_im"` } type UpsertRequest struct { @@ -54,4 +55,5 @@ type UpsertRequest struct { CompactionModelID *string `json:"compaction_model_id,omitempty"` DiscussProbeModelID string `json:"discuss_probe_model_id,omitempty"` PersistFullToolResults *bool `json:"persist_full_tool_results,omitempty"` + ShowToolCallsInIM *bool `json:"show_tool_calls_in_im,omitempty"` } diff --git a/packages/sdk/src/types.gen.ts b/packages/sdk/src/types.gen.ts index cf1a1d04..a420e143 100644 --- a/packages/sdk/src/types.gen.ts +++ b/packages/sdk/src/types.gen.ts @@ -1753,6 +1753,7 @@ export type SettingsSettings = { reasoning_effort?: string; reasoning_enabled?: boolean; search_provider_id?: string; + show_tool_calls_in_im?: boolean; timezone?: string; title_model_id?: string; transcription_model_id?: string; @@ -1778,6 +1779,7 @@ export type SettingsUpsertRequest = { reasoning_effort?: string; reasoning_enabled?: boolean; search_provider_id?: string; + show_tool_calls_in_im?: boolean; timezone?: string; title_model_id?: string; transcription_model_id?: string; diff --git a/spec/docs.go b/spec/docs.go index 4c4ff6c5..513e22bc 100644 --- a/spec/docs.go +++ b/spec/docs.go @@ -13783,6 +13783,9 @@ const docTemplate = `{ "search_provider_id": { "type": "string" }, + "show_tool_calls_in_im": { + "type": "boolean" + }, "timezone": { "type": "string" }, @@ -13854,6 +13857,9 @@ const docTemplate = `{ "search_provider_id": { "type": "string" }, + "show_tool_calls_in_im": { + "type": "boolean" + }, "timezone": { "type": "string" }, diff --git a/spec/swagger.json b/spec/swagger.json index 4c333ab8..6050c862 100644 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -13774,6 +13774,9 @@ "search_provider_id": { "type": "string" }, + "show_tool_calls_in_im": { + "type": "boolean" + }, "timezone": { "type": "string" }, @@ -13845,6 +13848,9 @@ "search_provider_id": { "type": "string" }, + "show_tool_calls_in_im": { + "type": "boolean" + }, "timezone": { "type": "string" }, diff --git a/spec/swagger.yaml b/spec/swagger.yaml index 38de73fe..e220c543 100644 --- a/spec/swagger.yaml +++ b/spec/swagger.yaml @@ -2947,6 +2947,8 @@ definitions: type: boolean search_provider_id: type: string + show_tool_calls_in_im: + type: boolean timezone: type: string title_model_id: @@ -2994,6 +2996,8 @@ definitions: type: boolean search_provider_id: type: string + show_tool_calls_in_im: + type: boolean timezone: type: string title_model_id: