mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(channel): structured tool-call IM display with edit-in-place
Introduce a new `show_tool_calls_in_im` bot setting plus a full overhaul of how tool calls are surfaced in IM channels: - Add per-bot setting + migration (0072) and expose through settings API / handlers / frontend SDK. - Introduce a `toolCallDroppingStream` wrapper that filters tool_call_* events when the setting is off, keeping the rest of the stream intact. - Add a shared `ToolCallPresentation` model (Header / Body blocks / Footer) with plain and Markdown renderers, and a per-tool formatter registry that produces rich output (e.g. `web_search` link lists, `list` directory previews, `exec` stdout/stderr tails) instead of raw JSON dumps. - High-capability adapters (Telegram, Feishu, Matrix, Slack, Discord) now flush pre-text and then send ONE tool-call message per call, editing it in-place from `running` to `completed` / `failed`; mapping from callID to platform message ID is tracked per stream, with a fallback to a new message if the edit fails. Low-capability adapters (WeCom, QQ, DingTalk) keep posting a single final message, but now benefit from the same rich per-tool formatting. - Suppress the early duplicate `EventToolCallStart` (from `sdk.ToolInputStartPart`) so that the SDK's final `StreamToolCallPart` remains the single source of truth for tool call start, preventing duplicated "running" bubbles in IM. - Stop auto-populating `InputSummary` / `ResultSummary` after a per-tool formatter runs, which previously leaked the raw JSON result as a fallback footer underneath the formatted body. Add regression tests for the formatters, the Markdown renderer, the edit-in-place flow on Telegram/Matrix, and the JSON-leak guard on `list`.
This commit is contained in:
@@ -374,6 +374,7 @@ func provideChannelRouter(
|
|||||||
processor.SetDispatcher(inbound.NewRouteDispatcher(log))
|
processor.SetDispatcher(inbound.NewRouteDispatcher(log))
|
||||||
processor.SetSpeechService(audioService, &settingsSpeechModelResolver{settings: settingsService})
|
processor.SetSpeechService(audioService, &settingsSpeechModelResolver{settings: settingsService})
|
||||||
processor.SetTranscriptionService(&settingsTranscriptionAdapter{audio: audioService}, &settingsTranscriptionModelResolver{settings: settingsService})
|
processor.SetTranscriptionService(&settingsTranscriptionAdapter{audio: audioService}, &settingsTranscriptionModelResolver{settings: settingsService})
|
||||||
|
processor.SetIMDisplayOptions(&settingsIMDisplayOptions{settings: settingsService})
|
||||||
cmdHandler := command.NewHandler(
|
cmdHandler := command.NewHandler(
|
||||||
log,
|
log,
|
||||||
&command.BotMemberRoleAdapter{BotService: botService},
|
&command.BotMemberRoleAdapter{BotService: botService},
|
||||||
@@ -597,6 +598,18 @@ func (r *settingsSpeechModelResolver) ResolveSpeechModelID(ctx context.Context,
|
|||||||
return s.TtsModelID, nil
|
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 {
|
type settingsTranscriptionModelResolver struct {
|
||||||
settings *settings.Service
|
settings *settings.Service
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ CREATE TABLE IF NOT EXISTS bots (
|
|||||||
transcription_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
|
transcription_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
|
||||||
browser_context_id UUID REFERENCES browser_contexts(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,
|
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,
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -21,7 +21,8 @@ SELECT
|
|||||||
tts_models.id AS tts_model_id,
|
tts_models.id AS tts_model_id,
|
||||||
transcription_models.id AS transcription_model_id,
|
transcription_models.id AS transcription_model_id,
|
||||||
browser_contexts.id AS browser_context_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
|
FROM bots
|
||||||
LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id
|
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
|
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),
|
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),
|
browser_context_id = COALESCE(sqlc.narg(browser_context_id)::uuid, bots.browser_context_id),
|
||||||
persist_full_tool_results = sqlc.arg(persist_full_tool_results),
|
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()
|
updated_at = now()
|
||||||
WHERE bots.id = sqlc.arg(id)
|
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
|
SELECT
|
||||||
updated.id AS bot_id,
|
updated.id AS bot_id,
|
||||||
@@ -85,7 +87,8 @@ SELECT
|
|||||||
tts_models.id AS tts_model_id,
|
tts_models.id AS tts_model_id,
|
||||||
transcription_models.id AS transcription_model_id,
|
transcription_models.id AS transcription_model_id,
|
||||||
browser_contexts.id AS browser_context_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
|
FROM updated
|
||||||
LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id
|
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
|
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,
|
transcription_model_id = NULL,
|
||||||
browser_context_id = NULL,
|
browser_context_id = NULL,
|
||||||
persist_full_tool_results = false,
|
persist_full_tool_results = false,
|
||||||
|
show_tool_calls_in_im = false,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|||||||
+8
-14
@@ -300,16 +300,14 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv
|
|||||||
}
|
}
|
||||||
|
|
||||||
case *sdk.ToolInputStartPart:
|
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 {
|
if textLoopProbeBuffer != nil {
|
||||||
textLoopProbeBuffer.Flush()
|
textLoopProbeBuffer.Flush()
|
||||||
}
|
}
|
||||||
if !sendEvent(ctx, ch, StreamEvent{
|
|
||||||
Type: EventToolCallStart,
|
|
||||||
ToolName: p.ToolName,
|
|
||||||
ToolCallID: p.ID,
|
|
||||||
}) {
|
|
||||||
aborted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
case *sdk.StreamToolCallPart:
|
case *sdk.StreamToolCallPart:
|
||||||
if textLoopProbeBuffer != nil {
|
if textLoopProbeBuffer != nil {
|
||||||
@@ -981,16 +979,12 @@ func (a *Agent) runMidStreamRetry(
|
|||||||
aborted = true
|
aborted = true
|
||||||
}
|
}
|
||||||
case *sdk.ToolInputStartPart:
|
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 {
|
if textLoopProbeBuffer != nil {
|
||||||
textLoopProbeBuffer.Flush()
|
textLoopProbeBuffer.Flush()
|
||||||
}
|
}
|
||||||
if !sendEvent(sendCtx, ch, StreamEvent{
|
|
||||||
Type: EventToolCallStart,
|
|
||||||
ToolName: rp.ToolName,
|
|
||||||
ToolCallID: rp.ID,
|
|
||||||
}) {
|
|
||||||
aborted = true
|
|
||||||
}
|
|
||||||
case *sdk.StreamToolCallPart:
|
case *sdk.StreamToolCallPart:
|
||||||
if textLoopProbeBuffer != nil {
|
if textLoopProbeBuffer != nil {
|
||||||
textLoopProbeBuffer.Flush()
|
textLoopProbeBuffer.Flush()
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ func (*agentToolPlaceholderProvider) DoStream(_ context.Context, _ sdk.GenerateP
|
|||||||
return &sdk.StreamResult{Stream: ch}, nil
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
a := New(Deps{})
|
a := New(Deps{})
|
||||||
@@ -64,26 +69,20 @@ func TestAgentStreamEmitsEarlyToolPlaceholderBeforeFullInput(t *testing.T) {
|
|||||||
events = append(events, event)
|
events = append(events, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(events) != 4 {
|
if len(events) != 3 {
|
||||||
t.Fatalf("expected 4 events, got %d: %#v", len(events), events)
|
t.Fatalf("expected 3 events, got %d: %#v", len(events), events)
|
||||||
}
|
}
|
||||||
if events[0].Type != EventAgentStart {
|
if events[0].Type != EventAgentStart {
|
||||||
t.Fatalf("expected first event %q, got %#v", EventAgentStart, events[0])
|
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" {
|
if events[1].Type != EventToolCallStart || events[1].ToolCallID != "call-1" || events[1].ToolName != "write" {
|
||||||
t.Fatalf("unexpected placeholder tool event: %#v", events[1])
|
t.Fatalf("unexpected tool call start 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])
|
|
||||||
}
|
}
|
||||||
expectedInput := map[string]any{"path": "/tmp/long.txt"}
|
expectedInput := map[string]any{"path": "/tmp/long.txt"}
|
||||||
if !reflect.DeepEqual(events[2].Input, expectedInput) {
|
if !reflect.DeepEqual(events[1].Input, expectedInput) {
|
||||||
t.Fatalf("expected full tool event input %#v, got %#v", expectedInput, events[2].Input)
|
t.Fatalf("expected tool call start input %#v, got %#v", expectedInput, events[1].Input)
|
||||||
}
|
}
|
||||||
if events[3].Type != EventAgentEnd {
|
if events[2].Type != EventAgentEnd {
|
||||||
t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[3])
|
t.Fatalf("expected terminal event %q, got %#v", EventAgentEnd, events[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ func (s *dingtalkOutboundStream) Push(ctx context.Context, event channel.Prepare
|
|||||||
channel.StreamEventPhaseStart,
|
channel.StreamEventPhaseStart,
|
||||||
channel.StreamEventPhaseEnd,
|
channel.StreamEventPhaseEnd,
|
||||||
channel.StreamEventToolCallStart,
|
channel.StreamEventToolCallStart,
|
||||||
channel.StreamEventToolCallEnd,
|
|
||||||
channel.StreamEventAgentStart,
|
channel.StreamEventAgentStart,
|
||||||
channel.StreamEventAgentEnd,
|
channel.StreamEventAgentEnd,
|
||||||
channel.StreamEventProcessingStarted,
|
channel.StreamEventProcessingStarted,
|
||||||
@@ -54,6 +53,18 @@ func (s *dingtalkOutboundStream) Push(ctx context.Context, event channel.Prepare
|
|||||||
// Non-content events: no-op.
|
// Non-content events: no-op.
|
||||||
return nil
|
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:
|
case channel.StreamEventDelta:
|
||||||
if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning {
|
if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type discordOutboundStream struct {
|
type discordOutboundStream struct {
|
||||||
adapter *DiscordAdapter
|
adapter *DiscordAdapter
|
||||||
cfg channel.ChannelConfig
|
cfg channel.ChannelConfig
|
||||||
target string
|
target string
|
||||||
reply *channel.ReplyRef
|
reply *channel.ReplyRef
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
msgID string
|
msgID string
|
||||||
buffer strings.Builder
|
buffer strings.Builder
|
||||||
lastUpdate time.Time
|
lastUpdate time.Time
|
||||||
|
toolMessages map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *discordOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error {
|
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
|
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
|
// Status events - no action needed for Discord
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
@@ -207,6 +222,82 @@ func (s *discordOutboundStream) finalizeMessage(text string) error {
|
|||||||
return err
|
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 {
|
func (s *discordOutboundStream) sendAttachment(ctx context.Context, att channel.PreparedAttachment) error {
|
||||||
file, err := discordPreparedAttachmentToFile(ctx, att)
|
file, err := discordPreparedAttachmentToFile(ctx, att)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -38,6 +39,7 @@ type feishuOutboundStream struct {
|
|||||||
lastPatched string
|
lastPatched string
|
||||||
patchInterval time.Duration
|
patchInterval time.Duration
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
|
toolMessages map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *feishuOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error {
|
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.lastPatched = ""
|
||||||
s.lastPatchedAt = time.Time{}
|
s.lastPatchedAt = time.Time{}
|
||||||
s.textBuffer.Reset()
|
s.textBuffer.Reset()
|
||||||
return nil
|
return s.renderToolCallCard(ctx, event.ToolCall, channel.BuildToolCallStart(event.ToolCall))
|
||||||
case channel.StreamEventToolCallEnd:
|
case channel.StreamEventToolCallEnd:
|
||||||
s.cardMessageID = ""
|
s.cardMessageID = ""
|
||||||
s.lastPatched = ""
|
s.lastPatched = ""
|
||||||
s.lastPatchedAt = time.Time{}
|
s.lastPatchedAt = time.Time{}
|
||||||
s.textBuffer.Reset()
|
s.textBuffer.Reset()
|
||||||
return nil
|
return s.renderToolCallCard(ctx, event.ToolCall, channel.BuildToolCallEnd(event.ToolCall))
|
||||||
case channel.StreamEventAttachment:
|
case channel.StreamEventAttachment:
|
||||||
if len(event.Attachments) == 0 {
|
if len(event.Attachments) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -367,6 +369,155 @@ func processFeishuCardMarkdown(s string) string {
|
|||||||
return s
|
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 {
|
func normalizeFeishuStreamText(text string) string {
|
||||||
trimmed := strings.TrimSpace(text)
|
trimmed := strings.TrimSpace(text)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type matrixOutboundStream struct {
|
|||||||
lastText string
|
lastText string
|
||||||
lastFormat channel.MessageFormat
|
lastFormat channel.MessageFormat
|
||||||
lastEditedAt time.Time
|
lastEditedAt time.Time
|
||||||
|
toolMessages map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *matrixOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error {
|
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 {
|
switch event.Type {
|
||||||
case channel.StreamEventStatus,
|
case channel.StreamEventStatus,
|
||||||
channel.StreamEventPhaseStart,
|
channel.StreamEventPhaseStart,
|
||||||
channel.StreamEventToolCallEnd,
|
|
||||||
channel.StreamEventAgentStart,
|
channel.StreamEventAgentStart,
|
||||||
channel.StreamEventAgentEnd,
|
channel.StreamEventAgentEnd,
|
||||||
channel.StreamEventProcessingStarted,
|
channel.StreamEventProcessingStarted,
|
||||||
@@ -60,8 +60,18 @@ func (s *matrixOutboundStream) Push(ctx context.Context, event channel.PreparedS
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return s.upsertText(ctx, text, channel.MessageFormatPlain, true)
|
return s.upsertText(ctx, text, channel.MessageFormatPlain, true)
|
||||||
case channel.StreamEventToolCallStart:
|
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()
|
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:
|
case channel.StreamEventDelta:
|
||||||
if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" {
|
if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -186,6 +196,98 @@ func (s *matrixOutboundStream) upsertText(ctx context.Context, text string, form
|
|||||||
return nil
|
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() {
|
func (s *matrixOutboundStream) resetMessageState() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.originalEventID = ""
|
s.originalEventID = ""
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestMatrixStreamDoesNotSendDeltaBeforeTextPhaseEnds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMatrixStreamDropsBufferedTextWhenToolStarts(t *testing.T) {
|
func TestMatrixStreamFlushesBufferedTextWhenToolStarts(t *testing.T) {
|
||||||
requests := 0
|
requests := 0
|
||||||
adapter := NewMatrixAdapter(nil)
|
adapter := NewMatrixAdapter(nil)
|
||||||
adapter.httpClient = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
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 {
|
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)
|
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)
|
t.Fatalf("push tool call start: %v", err)
|
||||||
}
|
}
|
||||||
if requests != 0 {
|
if requests != 2 {
|
||||||
t.Fatalf("expected no request for discarded pre-tool text, got %d", requests)
|
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 {
|
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)
|
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 {
|
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)
|
t.Fatalf("push final: %v", err)
|
||||||
}
|
}
|
||||||
if requests != 1 {
|
if requests != 4 {
|
||||||
t.Fatalf("expected only final visible message to be sent, got %d", requests)
|
t.Fatalf("expected final visible message after tool call, got %d", requests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,23 @@ func (s *qqOutboundStream) Push(ctx context.Context, event channel.PreparedStrea
|
|||||||
channel.StreamEventPhaseStart,
|
channel.StreamEventPhaseStart,
|
||||||
channel.StreamEventPhaseEnd,
|
channel.StreamEventPhaseEnd,
|
||||||
channel.StreamEventToolCallStart,
|
channel.StreamEventToolCallStart,
|
||||||
channel.StreamEventToolCallEnd,
|
|
||||||
channel.StreamEventAgentStart,
|
channel.StreamEventAgentStart,
|
||||||
channel.StreamEventAgentEnd,
|
channel.StreamEventAgentEnd,
|
||||||
channel.StreamEventProcessingStarted,
|
channel.StreamEventProcessingStarted,
|
||||||
channel.StreamEventProcessingCompleted,
|
channel.StreamEventProcessingCompleted,
|
||||||
channel.StreamEventProcessingFailed:
|
channel.StreamEventProcessingFailed:
|
||||||
return nil
|
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:
|
case channel.StreamEventDelta:
|
||||||
if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" {
|
if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -22,18 +22,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type slackOutboundStream struct {
|
type slackOutboundStream struct {
|
||||||
adapter *SlackAdapter
|
adapter *SlackAdapter
|
||||||
cfg channel.ChannelConfig
|
cfg channel.ChannelConfig
|
||||||
target string
|
target string
|
||||||
reply *channel.ReplyRef
|
reply *channel.ReplyRef
|
||||||
api *slackapi.Client
|
api *slackapi.Client
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
msgTS string // Slack message timestamp (used as message ID)
|
msgTS string // Slack message timestamp (used as message ID)
|
||||||
buffer strings.Builder
|
buffer strings.Builder
|
||||||
lastSent string
|
lastSent string
|
||||||
lastUpdate time.Time
|
lastUpdate time.Time
|
||||||
nextUpdate time.Time
|
nextUpdate time.Time
|
||||||
|
toolMessages map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ channel.PreparedOutboundStream = (*slackOutboundStream)(nil)
|
var _ channel.PreparedOutboundStream = (*slackOutboundStream)(nil)
|
||||||
@@ -122,11 +123,26 @@ func (s *slackOutboundStream) Push(ctx context.Context, event channel.PreparedSt
|
|||||||
}
|
}
|
||||||
return nil
|
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,
|
case channel.StreamEventAgentStart, channel.StreamEventAgentEnd,
|
||||||
channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd,
|
channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd,
|
||||||
channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted,
|
channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted,
|
||||||
channel.StreamEventProcessingFailed,
|
channel.StreamEventProcessingFailed,
|
||||||
channel.StreamEventToolCallStart, channel.StreamEventToolCallEnd,
|
|
||||||
channel.StreamEventReaction, channel.StreamEventSpeech:
|
channel.StreamEventReaction, channel.StreamEventSpeech:
|
||||||
return nil
|
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)
|
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) {
|
func (s *slackOutboundStream) postMessageWithRetry(ctx context.Context, text string) (string, error) {
|
||||||
opts := []slackapi.MsgOption{
|
opts := []slackapi.MsgOption{
|
||||||
slackapi.MsgOptionText(text, false),
|
slackapi.MsgOptionText(text, false),
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ type telegramOutboundStream struct {
|
|||||||
streamMsgID int
|
streamMsgID int
|
||||||
lastEdited string
|
lastEdited string
|
||||||
lastEditedAt time.Time
|
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) {
|
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)
|
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()
|
s.mu.Lock()
|
||||||
bufText := strings.TrimSpace(s.buf.String())
|
bufText := strings.TrimSpace(s.buf.String())
|
||||||
hasMsg := s.streamMsgID != 0
|
hasMsg := s.streamMsgID != 0
|
||||||
@@ -327,9 +336,120 @@ func (s *telegramOutboundStream) pushToolCallStart(ctx context.Context) error {
|
|||||||
_ = s.editStreamMessageFinal(ctx, bufText)
|
_ = s.editStreamMessageFinal(ctx, bufText)
|
||||||
}
|
}
|
||||||
s.resetStreamState()
|
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
|
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 {
|
func (s *telegramOutboundStream) pushAttachment(ctx context.Context, event channel.PreparedStreamEvent) error {
|
||||||
if len(event.Attachments) == 0 {
|
if len(event.Attachments) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -489,10 +609,9 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.Prepare
|
|||||||
}
|
}
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
case channel.StreamEventToolCallStart:
|
case channel.StreamEventToolCallStart:
|
||||||
return s.pushToolCallStart(ctx)
|
return s.pushToolCallStart(ctx, event.ToolCall)
|
||||||
case channel.StreamEventToolCallEnd:
|
case channel.StreamEventToolCallEnd:
|
||||||
s.resetStreamState()
|
return s.pushToolCallEnd(ctx, event.ToolCall)
|
||||||
return nil
|
|
||||||
case channel.StreamEventAttachment:
|
case channel.StreamEventAttachment:
|
||||||
return s.pushAttachment(ctx, event)
|
return s.pushAttachment(ctx, event)
|
||||||
case channel.StreamEventPhaseEnd:
|
case channel.StreamEventPhaseEnd:
|
||||||
|
|||||||
@@ -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) {
|
func TestDraftMode_ToolCallStartSendsPermanentMessage(t *testing.T) {
|
||||||
adapter := NewTelegramAdapter(nil)
|
adapter := NewTelegramAdapter(nil)
|
||||||
s := &telegramOutboundStream{
|
s := &telegramOutboundStream{
|
||||||
|
|||||||
@@ -278,13 +278,14 @@ func (s *wecomOutboundStream) Push(ctx context.Context, event channel.PreparedSt
|
|||||||
channel.StreamEventPhaseStart,
|
channel.StreamEventPhaseStart,
|
||||||
channel.StreamEventPhaseEnd,
|
channel.StreamEventPhaseEnd,
|
||||||
channel.StreamEventToolCallStart,
|
channel.StreamEventToolCallStart,
|
||||||
channel.StreamEventToolCallEnd,
|
|
||||||
channel.StreamEventAgentStart,
|
channel.StreamEventAgentStart,
|
||||||
channel.StreamEventAgentEnd,
|
channel.StreamEventAgentEnd,
|
||||||
channel.StreamEventProcessingStarted,
|
channel.StreamEventProcessingStarted,
|
||||||
channel.StreamEventProcessingCompleted,
|
channel.StreamEventProcessingCompleted,
|
||||||
channel.StreamEventProcessingFailed:
|
channel.StreamEventProcessingFailed:
|
||||||
return nil
|
return nil
|
||||||
|
case channel.StreamEventToolCallEnd:
|
||||||
|
return s.sendToolCallSummary(ctx, event.ToolCall)
|
||||||
case channel.StreamEventDelta:
|
case channel.StreamEventDelta:
|
||||||
if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning {
|
if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning {
|
||||||
return nil
|
return nil
|
||||||
@@ -363,6 +364,27 @@ func (s *wecomOutboundStream) flush(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (s *wecomOutboundStream) pushPreview(ctx context.Context) error {
|
||||||
if s.finalSent.Load() {
|
if s.finalSent.Load() {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ type SessionEnsurer interface {
|
|||||||
CreateNewSession(ctx context.Context, botID, routeID, channelType, sessionType string) (SessionResult, error)
|
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.
|
// SessionResult carries the minimum fields needed from a session.
|
||||||
type SessionResult struct {
|
type SessionResult struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -124,6 +133,7 @@ type ChannelInboundProcessor struct {
|
|||||||
pipeline *pipelinepkg.Pipeline
|
pipeline *pipelinepkg.Pipeline
|
||||||
eventStore *pipelinepkg.EventStore
|
eventStore *pipelinepkg.EventStore
|
||||||
discussDriver *pipelinepkg.DiscussDriver
|
discussDriver *pipelinepkg.DiscussDriver
|
||||||
|
imDisplayOptions IMDisplayOptionsReader
|
||||||
|
|
||||||
// activeStreams maps "botID:routeID" to a context.CancelFunc for the
|
// activeStreams maps "botID:routeID" to a context.CancelFunc for the
|
||||||
// currently running agent stream. Used by /stop to abort generation
|
// currently running agent stream. Used by /stop to abort generation
|
||||||
@@ -259,6 +269,42 @@ func (p *ChannelInboundProcessor) SetDispatcher(dispatcher *RouteDispatcher) {
|
|||||||
p.dispatcher = dispatcher
|
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.
|
// 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) {
|
func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, sender channel.StreamReplySender) (retErr error) {
|
||||||
if p.runner == nil {
|
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
|
// For non-local channels, wrap the stream so events are mirrored to the
|
||||||
// RouteHub (and thus to Web UI and other local subscribers).
|
// RouteHub (and thus to Web UI and other local subscribers).
|
||||||
if p.observer != nil && !isLocalChannelType(msg.Channel) {
|
if p.observer != nil && !isLocalChannelType(msg.Channel) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 != "<nil>" {
|
||||||
|
return truncateSummary(fmt.Sprintf("%s · %s", s, cron))
|
||||||
|
}
|
||||||
|
if action := strings.TrimSpace(fmt.Sprint(m["action"])); action != "" && action != "<nil>" {
|
||||||
|
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("<binary %d bytes>", 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -511,7 +511,7 @@ WITH updated AS (
|
|||||||
SET display_name = $1,
|
SET display_name = $1,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE bots.id = $2
|
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
|
SELECT
|
||||||
updated.id AS id,
|
updated.id AS id,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Bot struct {
|
|||||||
TranscriptionModelID pgtype.UUID `json:"transcription_model_id"`
|
TranscriptionModelID pgtype.UUID `json:"transcription_model_id"`
|
||||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||||
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
||||||
|
ShowToolCallsInIm bool `json:"show_tool_calls_in_im"`
|
||||||
Metadata []byte `json:"metadata"`
|
Metadata []byte `json:"metadata"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ SET language = 'auto',
|
|||||||
transcription_model_id = NULL,
|
transcription_model_id = NULL,
|
||||||
browser_context_id = NULL,
|
browser_context_id = NULL,
|
||||||
persist_full_tool_results = false,
|
persist_full_tool_results = false,
|
||||||
|
show_tool_calls_in_im = false,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -65,7 +66,8 @@ SELECT
|
|||||||
tts_models.id AS tts_model_id,
|
tts_models.id AS tts_model_id,
|
||||||
transcription_models.id AS transcription_model_id,
|
transcription_models.id AS transcription_model_id,
|
||||||
browser_contexts.id AS browser_context_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
|
FROM bots
|
||||||
LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id
|
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
|
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"`
|
TranscriptionModelID pgtype.UUID `json:"transcription_model_id"`
|
||||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||||
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
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) {
|
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.TranscriptionModelID,
|
||||||
&i.BrowserContextID,
|
&i.BrowserContextID,
|
||||||
&i.PersistFullToolResults,
|
&i.PersistFullToolResults,
|
||||||
|
&i.ShowToolCallsInIm,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -159,9 +163,10 @@ WITH updated AS (
|
|||||||
transcription_model_id = COALESCE($19::uuid, bots.transcription_model_id),
|
transcription_model_id = COALESCE($19::uuid, bots.transcription_model_id),
|
||||||
browser_context_id = COALESCE($20::uuid, bots.browser_context_id),
|
browser_context_id = COALESCE($20::uuid, bots.browser_context_id),
|
||||||
persist_full_tool_results = $21,
|
persist_full_tool_results = $21,
|
||||||
|
show_tool_calls_in_im = $22,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE bots.id = $22
|
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
|
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
|
SELECT
|
||||||
updated.id AS bot_id,
|
updated.id AS bot_id,
|
||||||
@@ -185,7 +190,8 @@ SELECT
|
|||||||
tts_models.id AS tts_model_id,
|
tts_models.id AS tts_model_id,
|
||||||
transcription_models.id AS transcription_model_id,
|
transcription_models.id AS transcription_model_id,
|
||||||
browser_contexts.id AS browser_context_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
|
FROM updated
|
||||||
LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id
|
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
|
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"`
|
TranscriptionModelID pgtype.UUID `json:"transcription_model_id"`
|
||||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||||
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
||||||
|
ShowToolCallsInIm bool `json:"show_tool_calls_in_im"`
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +254,7 @@ type UpsertBotSettingsRow struct {
|
|||||||
TranscriptionModelID pgtype.UUID `json:"transcription_model_id"`
|
TranscriptionModelID pgtype.UUID `json:"transcription_model_id"`
|
||||||
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
BrowserContextID pgtype.UUID `json:"browser_context_id"`
|
||||||
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
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) {
|
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.TranscriptionModelID,
|
||||||
arg.BrowserContextID,
|
arg.BrowserContextID,
|
||||||
arg.PersistFullToolResults,
|
arg.PersistFullToolResults,
|
||||||
|
arg.ShowToolCallsInIm,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
var i UpsertBotSettingsRow
|
var i UpsertBotSettingsRow
|
||||||
@@ -298,6 +307,7 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
|
|||||||
&i.TranscriptionModelID,
|
&i.TranscriptionModelID,
|
||||||
&i.BrowserContextID,
|
&i.BrowserContextID,
|
||||||
&i.PersistFullToolResults,
|
&i.PersistFullToolResults,
|
||||||
|
&i.ShowToolCallsInIm,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
|
|||||||
if req.PersistFullToolResults != nil {
|
if req.PersistFullToolResults != nil {
|
||||||
current.PersistFullToolResults = *req.PersistFullToolResults
|
current.PersistFullToolResults = *req.PersistFullToolResults
|
||||||
}
|
}
|
||||||
|
if req.ShowToolCallsInIM != nil {
|
||||||
|
current.ShowToolCallsInIM = *req.ShowToolCallsInIM
|
||||||
|
}
|
||||||
timezoneValue := pgtype.Text{}
|
timezoneValue := pgtype.Text{}
|
||||||
if req.Timezone != nil {
|
if req.Timezone != nil {
|
||||||
normalized, err := normalizeOptionalTimezone(*req.Timezone)
|
normalized, err := normalizeOptionalTimezone(*req.Timezone)
|
||||||
@@ -215,6 +218,7 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
|
|||||||
TranscriptionModelID: transcriptionModelUUID,
|
TranscriptionModelID: transcriptionModelUUID,
|
||||||
BrowserContextID: browserContextUUID,
|
BrowserContextID: browserContextUUID,
|
||||||
PersistFullToolResults: current.PersistFullToolResults,
|
PersistFullToolResults: current.PersistFullToolResults,
|
||||||
|
ShowToolCallsInIm: current.ShowToolCallsInIM,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, err
|
return Settings{}, err
|
||||||
@@ -310,6 +314,7 @@ func normalizeBotSettingsReadRow(row sqlc.GetSettingsByBotIDRow) Settings {
|
|||||||
row.TranscriptionModelID,
|
row.TranscriptionModelID,
|
||||||
row.BrowserContextID,
|
row.BrowserContextID,
|
||||||
row.PersistFullToolResults,
|
row.PersistFullToolResults,
|
||||||
|
row.ShowToolCallsInIm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +340,7 @@ func normalizeBotSettingsWriteRow(row sqlc.UpsertBotSettingsRow) Settings {
|
|||||||
row.TranscriptionModelID,
|
row.TranscriptionModelID,
|
||||||
row.BrowserContextID,
|
row.BrowserContextID,
|
||||||
row.PersistFullToolResults,
|
row.PersistFullToolResults,
|
||||||
|
row.ShowToolCallsInIm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +365,7 @@ func normalizeBotSettingsFields(
|
|||||||
transcriptionModelID pgtype.UUID,
|
transcriptionModelID pgtype.UUID,
|
||||||
browserContextID pgtype.UUID,
|
browserContextID pgtype.UUID,
|
||||||
persistFullToolResults bool,
|
persistFullToolResults bool,
|
||||||
|
showToolCallsInIM bool,
|
||||||
) Settings {
|
) Settings {
|
||||||
settings := normalizeBotSetting(language, "", reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold, compactionRatio)
|
settings := normalizeBotSetting(language, "", reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold, compactionRatio)
|
||||||
if timezone.Valid {
|
if timezone.Valid {
|
||||||
@@ -395,6 +402,7 @@ func normalizeBotSettingsFields(
|
|||||||
settings.BrowserContextID = uuid.UUID(browserContextID.Bytes).String()
|
settings.BrowserContextID = uuid.UUID(browserContextID.Bytes).String()
|
||||||
}
|
}
|
||||||
settings.PersistFullToolResults = persistFullToolResults
|
settings.PersistFullToolResults = persistFullToolResults
|
||||||
|
settings.ShowToolCallsInIM = showToolCallsInIM
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type Settings struct {
|
|||||||
CompactionModelID string `json:"compaction_model_id,omitempty"`
|
CompactionModelID string `json:"compaction_model_id,omitempty"`
|
||||||
DiscussProbeModelID string `json:"discuss_probe_model_id,omitempty"`
|
DiscussProbeModelID string `json:"discuss_probe_model_id,omitempty"`
|
||||||
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
PersistFullToolResults bool `json:"persist_full_tool_results"`
|
||||||
|
ShowToolCallsInIM bool `json:"show_tool_calls_in_im"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpsertRequest struct {
|
type UpsertRequest struct {
|
||||||
@@ -54,4 +55,5 @@ type UpsertRequest struct {
|
|||||||
CompactionModelID *string `json:"compaction_model_id,omitempty"`
|
CompactionModelID *string `json:"compaction_model_id,omitempty"`
|
||||||
DiscussProbeModelID string `json:"discuss_probe_model_id,omitempty"`
|
DiscussProbeModelID string `json:"discuss_probe_model_id,omitempty"`
|
||||||
PersistFullToolResults *bool `json:"persist_full_tool_results,omitempty"`
|
PersistFullToolResults *bool `json:"persist_full_tool_results,omitempty"`
|
||||||
|
ShowToolCallsInIM *bool `json:"show_tool_calls_in_im,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1753,6 +1753,7 @@ export type SettingsSettings = {
|
|||||||
reasoning_effort?: string;
|
reasoning_effort?: string;
|
||||||
reasoning_enabled?: boolean;
|
reasoning_enabled?: boolean;
|
||||||
search_provider_id?: string;
|
search_provider_id?: string;
|
||||||
|
show_tool_calls_in_im?: boolean;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
title_model_id?: string;
|
title_model_id?: string;
|
||||||
transcription_model_id?: string;
|
transcription_model_id?: string;
|
||||||
@@ -1778,6 +1779,7 @@ export type SettingsUpsertRequest = {
|
|||||||
reasoning_effort?: string;
|
reasoning_effort?: string;
|
||||||
reasoning_enabled?: boolean;
|
reasoning_enabled?: boolean;
|
||||||
search_provider_id?: string;
|
search_provider_id?: string;
|
||||||
|
show_tool_calls_in_im?: boolean;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
title_model_id?: string;
|
title_model_id?: string;
|
||||||
transcription_model_id?: string;
|
transcription_model_id?: string;
|
||||||
|
|||||||
@@ -13783,6 +13783,9 @@ const docTemplate = `{
|
|||||||
"search_provider_id": {
|
"search_provider_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"show_tool_calls_in_im": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -13854,6 +13857,9 @@ const docTemplate = `{
|
|||||||
"search_provider_id": {
|
"search_provider_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"show_tool_calls_in_im": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13774,6 +13774,9 @@
|
|||||||
"search_provider_id": {
|
"search_provider_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"show_tool_calls_in_im": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -13845,6 +13848,9 @@
|
|||||||
"search_provider_id": {
|
"search_provider_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"show_tool_calls_in_im": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2947,6 +2947,8 @@ definitions:
|
|||||||
type: boolean
|
type: boolean
|
||||||
search_provider_id:
|
search_provider_id:
|
||||||
type: string
|
type: string
|
||||||
|
show_tool_calls_in_im:
|
||||||
|
type: boolean
|
||||||
timezone:
|
timezone:
|
||||||
type: string
|
type: string
|
||||||
title_model_id:
|
title_model_id:
|
||||||
@@ -2994,6 +2996,8 @@ definitions:
|
|||||||
type: boolean
|
type: boolean
|
||||||
search_provider_id:
|
search_provider_id:
|
||||||
type: string
|
type: string
|
||||||
|
show_tool_calls_in_im:
|
||||||
|
type: boolean
|
||||||
timezone:
|
timezone:
|
||||||
type: string
|
type: string
|
||||||
title_model_id:
|
title_model_id:
|
||||||
|
|||||||
Reference in New Issue
Block a user