feat(channels): unify tool feedback animation across discord telegram and feishu
This commit is contained in:
parent
72f30c58e9
commit
04733d9f37
18 changed files with 1347 additions and 77 deletions
|
|
@ -8,26 +8,56 @@ Discord is a free voice, video, and text chat application designed for communiti
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"tool_feedback": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_args_length": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"channel_list": {
|
"channel_list": {
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "discord",
|
"type": "discord",
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": ["Thinking... 💭"]
|
||||||
|
},
|
||||||
"group_trigger": {
|
"group_trigger": {
|
||||||
"mention_only": false
|
"mention_only": false
|
||||||
}
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
|
| -------------------- | ------ | -------- | --------------------------------------------------------------------------- |
|
||||||
| enabled | bool | Yes | Whether to enable the Discord channel |
|
| enabled | bool | Yes | Whether to enable the Discord channel |
|
||||||
| token | string | Yes | Discord Bot Token |
|
| token | string | Yes | Discord Bot Token |
|
||||||
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||||
| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
|
| placeholder | object | No | Placeholder message config shown while the agent is working |
|
||||||
|
| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
|
||||||
|
| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output |
|
||||||
|
|
||||||
|
## Visible Execution Feedback
|
||||||
|
|
||||||
|
Discord can show three different kinds of "working" feedback:
|
||||||
|
|
||||||
|
1. Typing indicator: automatic, no extra config needed.
|
||||||
|
2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply.
|
||||||
|
3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
🔧 `web_search`
|
||||||
|
{"query":"picoclaw release notes"}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ const (
|
||||||
pendingTurnPrefix = "pending-"
|
pendingTurnPrefix = "pending-"
|
||||||
metadataKeyMessageKind = "message_kind"
|
metadataKeyMessageKind = "message_kind"
|
||||||
messageKindThought = "thought"
|
messageKindThought = "thought"
|
||||||
|
messageKindToolFeedback = "tool_feedback"
|
||||||
metadataKeyAccountID = "account_id"
|
metadataKeyAccountID = "account_id"
|
||||||
metadataKeyGuildID = "guild_id"
|
metadataKeyGuildID = "guild_id"
|
||||||
metadataKeyTeamID = "team_id"
|
metadataKeyTeamID = "team_id"
|
||||||
|
|
@ -838,6 +839,62 @@ func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage {
|
||||||
|
msg := outboundMessageForTurn(ts, content)
|
||||||
|
if strings.TrimSpace(kind) == "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
if msg.Context.Raw == nil {
|
||||||
|
msg.Context.Raw = make(map[string]string, 1)
|
||||||
|
}
|
||||||
|
msg.Context.Raw[metadataKeyMessageKind] = kind
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousAssistantContent(messages []providers.Message) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
msg := messages[i]
|
||||||
|
if msg.Role == "user" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if msg.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if content := strings.TrimSpace(msg.Content); content != "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolFeedbackExplanationFromResponse(
|
||||||
|
response *providers.LLMResponse,
|
||||||
|
messages []providers.Message,
|
||||||
|
maxLen int,
|
||||||
|
) string {
|
||||||
|
if response == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
explanation := strings.TrimSpace(response.Content)
|
||||||
|
if explanation == "" {
|
||||||
|
explanation = strings.TrimSpace(response.Reasoning)
|
||||||
|
}
|
||||||
|
if explanation == "" {
|
||||||
|
explanation = strings.TrimSpace(response.ReasoningContent)
|
||||||
|
}
|
||||||
|
if explanation == "" {
|
||||||
|
explanation = previousAssistantContent(messages)
|
||||||
|
}
|
||||||
|
return utils.Truncate(explanation, maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
|
||||||
|
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
// MountHook registers an in-process hook on the agent loop.
|
// MountHook registers an in-process hook on the agent loop.
|
||||||
func (al *AgentLoop) MountHook(reg HookRegistration) error {
|
func (al *AgentLoop) MountHook(reg HookRegistration) error {
|
||||||
if al == nil || al.hooks == nil {
|
if al == nil || al.hooks == nil {
|
||||||
|
|
@ -2666,6 +2723,11 @@ turnLoop:
|
||||||
"count": len(normalizedToolCalls),
|
"count": len(normalizedToolCalls),
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
toolFeedbackExplanation := toolFeedbackExplanationFromResponse(
|
||||||
|
response,
|
||||||
|
messages,
|
||||||
|
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
||||||
|
)
|
||||||
|
|
||||||
allResponsesHandled := len(normalizedToolCalls) > 0
|
allResponsesHandled := len(normalizedToolCalls) > 0
|
||||||
assistantMsg := providers.Message{
|
assistantMsg := providers.Message{
|
||||||
|
|
@ -2753,21 +2815,10 @@ turnLoop:
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send tool feedback to chat channel if enabled (same as normal tool execution)
|
// Send tool feedback to chat channel if enabled (same as normal tool execution)
|
||||||
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() &&
|
if shouldPublishToolFeedback(al.cfg, ts) {
|
||||||
ts.channel != "" &&
|
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation)
|
||||||
!ts.opts.SuppressToolFeedback {
|
|
||||||
argsJSON, _ := json.Marshal(toolArgs)
|
|
||||||
feedbackPreview := utils.Truncate(
|
|
||||||
string(argsJSON),
|
|
||||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
|
||||||
)
|
|
||||||
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview)
|
|
||||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||||
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
|
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||||
Channel: ts.channel,
|
|
||||||
ChatID: ts.chatID,
|
|
||||||
Content: feedbackMsg,
|
|
||||||
})
|
|
||||||
fbCancel()
|
fbCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3037,16 +3088,10 @@ turnLoop:
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send tool feedback to chat channel if enabled (from HEAD)
|
// Send tool feedback to chat channel if enabled (from HEAD)
|
||||||
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() &&
|
if shouldPublishToolFeedback(al.cfg, ts) {
|
||||||
ts.channel != "" &&
|
feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, toolFeedbackExplanation)
|
||||||
!ts.opts.SuppressToolFeedback {
|
|
||||||
feedbackPreview := utils.Truncate(
|
|
||||||
string(argsJSON),
|
|
||||||
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
|
|
||||||
)
|
|
||||||
feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview)
|
|
||||||
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
|
||||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg))
|
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||||
fbCancel()
|
fbCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1758,6 +1758,92 @@ func (m *toolFeedbackProvider) GetDefaultModel() string {
|
||||||
return "heartbeat-tool-feedback-model"
|
return "heartbeat-tool-feedback-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type toolFeedbackReasoningProvider struct {
|
||||||
|
filePath string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *toolFeedbackReasoningProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
if m.calls == 1 {
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
ReasoningContent: "Read README.md first to confirm the context that needs to be changed.",
|
||||||
|
ToolCalls: []providers.ToolCall{{
|
||||||
|
ID: "call_reasoning_read_file",
|
||||||
|
Type: "function",
|
||||||
|
Name: "read_file",
|
||||||
|
Arguments: map[string]any{"path": m.filePath},
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: "DONE",
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *toolFeedbackReasoningProvider) GetDefaultModel() string {
|
||||||
|
return "tool-feedback-reasoning-model"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) {
|
||||||
|
response := &providers.LLMResponse{
|
||||||
|
Content: "Read README.md first",
|
||||||
|
ReasoningContent: "current reasoning fallback",
|
||||||
|
}
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "check file"},
|
||||||
|
{Role: "assistant", Content: "Previous turn explanation"},
|
||||||
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := toolFeedbackExplanationFromResponse(response, messages, 300)
|
||||||
|
if got != "Read README.md first" {
|
||||||
|
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackExplanationFromResponse_FallsBackToReasoningContent(t *testing.T) {
|
||||||
|
response := &providers.LLMResponse{
|
||||||
|
Content: "",
|
||||||
|
ReasoningContent: "current reasoning fallback",
|
||||||
|
}
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "check file"},
|
||||||
|
{Role: "assistant", Content: ""},
|
||||||
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := toolFeedbackExplanationFromResponse(response, messages, 300)
|
||||||
|
if got != "current reasoning fallback" {
|
||||||
|
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want reasoning fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackExplanationFromResponse_UsesPreviousAssistantContentAsLastResort(t *testing.T) {
|
||||||
|
response := &providers.LLMResponse{
|
||||||
|
Content: "",
|
||||||
|
ReasoningContent: "",
|
||||||
|
}
|
||||||
|
messages := []providers.Message{
|
||||||
|
{Role: "user", Content: "check file"},
|
||||||
|
{Role: "assistant", Content: "Previous turn explanation"},
|
||||||
|
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := toolFeedbackExplanationFromResponse(response, messages, 300)
|
||||||
|
if got != "Previous turn explanation" {
|
||||||
|
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want previous assistant content", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type picoInterleavedContentProvider struct {
|
type picoInterleavedContentProvider struct {
|
||||||
calls int
|
calls int
|
||||||
}
|
}
|
||||||
|
|
@ -3656,7 +3742,10 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
t.Fatalf("unexpected tool feedback context: %+v", outbound.Context)
|
t.Fatalf("unexpected tool feedback context: %+v", outbound.Context)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outbound.Content, "`read_file`") {
|
if !strings.Contains(outbound.Content, "`read_file`") {
|
||||||
t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content)
|
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||||
|
}
|
||||||
|
if strings.Contains(outbound.Content, "Why this tool should be executed") {
|
||||||
|
t.Fatalf("tool feedback content = %q, want no fallback explanation", outbound.Content)
|
||||||
}
|
}
|
||||||
if outbound.AgentID != "main" {
|
if outbound.AgentID != "main" {
|
||||||
t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID)
|
t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID)
|
||||||
|
|
@ -3672,6 +3761,204 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_PublishesToolFeedbackFromReasoningContent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt")
|
||||||
|
if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
ToolFeedback: config.ToolFeedbackConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxArgsLength: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
ReadFile: config.ReadFileToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &toolFeedbackReasoningProvider{filePath: heartbeatFile}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "check reasoning fallback",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "DONE" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "DONE")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
if !strings.Contains(outbound.Content, "`read_file`") {
|
||||||
|
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(outbound.Content, "Read README.md first") {
|
||||||
|
t.Fatalf("tool feedback content = %q, want reasoning fallback", outbound.Content)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected outbound tool feedback for reasoning fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
heartbeatFile := filepath.Join(tmpDir, "tool-feedback-discord.txt")
|
||||||
|
if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
ReadFile: config.ReadFileToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "discord",
|
||||||
|
SenderID: "user-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "check tool feedback",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "HEARTBEAT_OK" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
t.Fatalf("expected no outbound tool feedback for discord when disabled, got %+v", outbound)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
heartbeatFile := filepath.Join(tmpDir, "tool-feedback-telegram-default.txt")
|
||||||
|
if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
ReadFile: config.ReadFileToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "check tool feedback",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "HEARTBEAT_OK" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
t.Fatalf("expected no outbound tool feedback for telegram when disabled, got %+v", outbound)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
heartbeatFile := filepath.Join(tmpDir, "tool-feedback-feishu-default.txt")
|
||||||
|
if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
ReadFile: config.ReadFileToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &toolFeedbackProvider{filePath: heartbeatFile}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "feishu",
|
||||||
|
SenderID: "user-1",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "check tool feedback",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "HEARTBEAT_OK" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
t.Fatalf("expected no outbound tool feedback for feishu when disabled, got %+v", outbound)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) {
|
func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Workspace = t.TempDir()
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ type DiscordChannel struct {
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
typingMu sync.Mutex
|
typingMu sync.Mutex
|
||||||
typingStop map[string]chan struct{} // chatID → stop signal
|
typingStop map[string]chan struct{} // chatID → stop signal
|
||||||
botUserID string // stored for mention checking
|
progress *channels.ToolFeedbackAnimator
|
||||||
|
botUserID string // stored for mention checking
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
tts tts.TTSProvider
|
tts tts.TTSProvider
|
||||||
voiceMu sync.RWMutex
|
voiceMu sync.RWMutex
|
||||||
|
|
@ -84,7 +85,7 @@ func NewDiscordChannel(
|
||||||
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
||||||
return &DiscordChannel{
|
ch := &DiscordChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
bc: bc,
|
bc: bc,
|
||||||
session: session,
|
session: session,
|
||||||
|
|
@ -93,7 +94,9 @@ func NewDiscordChannel(
|
||||||
typingStop: make(map[string]chan struct{}),
|
typingStop: make(map[string]chan struct{}),
|
||||||
bus: bus,
|
bus: bus,
|
||||||
voiceSSRC: make(map[string]map[uint32]string),
|
voiceSSRC: make(map[string]map[uint32]string),
|
||||||
}, nil
|
}
|
||||||
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) Start(ctx context.Context) error {
|
func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||||
|
|
@ -142,6 +145,9 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
if c.progress != nil {
|
||||||
|
c.progress.StopAll()
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.session.Close(); err != nil {
|
if err := c.session.Close(); err != nil {
|
||||||
return fmt.Errorf("failed to close discord session: %w", err)
|
return fmt.Errorf("failed to close discord session: %w", err)
|
||||||
|
|
@ -164,7 +170,21 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.tts != nil {
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||||
|
if isToolFeedback {
|
||||||
|
animatedContent := channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
||||||
|
if msgID, ok := c.currentToolFeedbackMessage(channelID); ok {
|
||||||
|
if err := c.EditMessage(ctx, channelID, msgID, animatedContent); err == nil {
|
||||||
|
c.RecordToolFeedbackMessage(channelID, msgID, msg.Content)
|
||||||
|
return []string{msgID}, nil
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(channelID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.DismissToolFeedbackMessage(ctx, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.tts != nil && !isToolFeedback {
|
||||||
if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" {
|
if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" {
|
||||||
if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil {
|
if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil {
|
||||||
// Cancel any previous TTS playback
|
// Cancel any previous TTS playback
|
||||||
|
|
@ -183,10 +203,19 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
|
content := msg.Content
|
||||||
|
if isToolFeedback {
|
||||||
|
content = channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
||||||
|
}
|
||||||
|
msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if isToolFeedback {
|
||||||
|
c.RecordToolFeedbackMessage(channelID, msgID, msg.Content)
|
||||||
|
} else {
|
||||||
|
c.ClearToolFeedbackMessage(channelID)
|
||||||
|
}
|
||||||
return []string{msgID}, nil
|
return []string{msgID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +229,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes
|
||||||
if channelID == "" {
|
if channelID == "" {
|
||||||
return nil, fmt.Errorf("channel ID is empty")
|
return nil, fmt.Errorf("channel ID is empty")
|
||||||
}
|
}
|
||||||
|
c.DismissToolFeedbackMessage(ctx, channelID)
|
||||||
|
|
||||||
store := c.GetMediaStore()
|
store := c.GetMediaStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
|
|
@ -299,6 +329,11 @@ func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, message
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteMessage implements channels.MessageDeleter.
|
||||||
|
func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
||||||
|
return c.session.ChannelMessageDelete(chatID, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
// SendPlaceholder implements channels.PlaceholderCapable.
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
// It sends a placeholder message that will later be edited to the actual
|
// It sends a placeholder message that will later be edited to the actual
|
||||||
// response via EditMessage (channels.MessageEditor).
|
// response via EditMessage (channels.MessageEditor).
|
||||||
|
|
@ -317,6 +352,43 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
|
||||||
return msg.ID, nil
|
return msg.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return c.progress.Current(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.progress.Record(chatID, messageID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.progress.Clear(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
|
||||||
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(chatID)
|
||||||
|
_ = c.DeleteMessage(ctx, chatID, msgID)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) {
|
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) {
|
||||||
// Use the passed ctx for timeout control
|
// Use the passed ctx for timeout control
|
||||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplyDiscordProxy_CustomProxy(t *testing.T) {
|
func TestApplyDiscordProxy_CustomProxy(t *testing.T) {
|
||||||
|
|
@ -89,3 +96,78 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) {
|
||||||
t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil")
|
t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
requests []string
|
||||||
|
)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
requests = append(requests, r.Method+" "+r.URL.Path)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodDelete && r.URL.Path == "/channels/chat-1/messages/prog-1":
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/channels/chat-1/messages":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"id":"final-1"}`)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origChannels := discordgo.EndpointChannels
|
||||||
|
discordgo.EndpointChannels = server.URL + "/channels/"
|
||||||
|
defer func() {
|
||||||
|
discordgo.EndpointChannels = origChannels
|
||||||
|
}()
|
||||||
|
|
||||||
|
session, err := discordgo.New("Bot test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discordgo.New() error: %v", err)
|
||||||
|
}
|
||||||
|
session.Client = server.Client()
|
||||||
|
|
||||||
|
ch := &DiscordChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil),
|
||||||
|
session: session,
|
||||||
|
ctx: context.Background(),
|
||||||
|
typingStop: make(map[string]chan struct{}),
|
||||||
|
voiceSSRC: make(map[string]map[uint32]string),
|
||||||
|
}
|
||||||
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
|
ch.SetRunning(true)
|
||||||
|
ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`")
|
||||||
|
|
||||||
|
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "chat-1",
|
||||||
|
Content: "final reply",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "discord",
|
||||||
|
ChatID: "chat-1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Send() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := ids, []string{"final-1"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("Send() ids = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok {
|
||||||
|
t.Fatal("expected tracked tool feedback message to be cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
wantRequests := []string{
|
||||||
|
"DELETE /channels/chat-1/messages/prog-1",
|
||||||
|
"POST /channels/chat-1/messages",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(requests, wantRequests) {
|
||||||
|
t.Fatalf("requests = %v, want %v", requests, wantRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ type FeishuChannel struct {
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
progress *channels.ToolFeedbackAnimator
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedMessage struct {
|
type cachedMessage struct {
|
||||||
|
|
@ -74,6 +76,7 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M
|
||||||
tokenCache: tc,
|
tokenCache: tc,
|
||||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
||||||
}
|
}
|
||||||
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
ch.SetOwner(ch)
|
ch.SetOwner(ch)
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +135,9 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.wsClient = nil
|
c.wsClient = nil
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
if c.progress != nil {
|
||||||
|
c.progress.StopAll()
|
||||||
|
}
|
||||||
|
|
||||||
c.SetRunning(false)
|
c.SetRunning(false)
|
||||||
logger.InfoC("feishu", "Feishu channel stopped")
|
logger.InfoC("feishu", "Feishu channel stopped")
|
||||||
|
|
@ -149,17 +155,53 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st
|
||||||
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||||
|
if isToolFeedback {
|
||||||
|
animatedContent := channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
||||||
|
if msgID, ok := c.currentToolFeedbackMessage(msg.ChatID); ok {
|
||||||
|
if err := c.EditMessage(ctx, msg.ChatID, msgID, animatedContent); err == nil {
|
||||||
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
||||||
|
return []string{msgID}, nil
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(msg.ChatID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled {
|
||||||
|
return msgIDs, nil
|
||||||
|
}
|
||||||
|
c.DismissToolFeedbackMessage(ctx, msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
// Build interactive card with markdown content
|
// Build interactive card with markdown content
|
||||||
cardContent, err := buildMarkdownCard(msg.Content)
|
sendContent := msg.Content
|
||||||
|
if isToolFeedback {
|
||||||
|
sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
||||||
|
}
|
||||||
|
cardContent, err := buildMarkdownCard(sendContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If card build fails, fall back to plain text
|
// If card build fails, fall back to plain text
|
||||||
return nil, c.sendText(ctx, msg.ChatID, msg.Content)
|
msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent)
|
||||||
|
if sendErr == nil && isToolFeedback {
|
||||||
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
||||||
|
}
|
||||||
|
if !isToolFeedback {
|
||||||
|
c.ClearToolFeedbackMessage(msg.ChatID)
|
||||||
|
}
|
||||||
|
if sendErr != nil {
|
||||||
|
return nil, sendErr
|
||||||
|
}
|
||||||
|
return []string{msgID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// First attempt: try sending as interactive card
|
// First attempt: try sending as interactive card
|
||||||
err = c.sendCard(ctx, msg.ChatID, cardContent)
|
msgID, err := c.sendCard(ctx, msg.ChatID, cardContent)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil, nil
|
if isToolFeedback {
|
||||||
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
||||||
|
} else {
|
||||||
|
c.ClearToolFeedbackMessage(msg.ChatID)
|
||||||
|
}
|
||||||
|
return []string{msgID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error is due to card table limit (error code 11310)
|
// Check if error is due to card table limit (error code 11310)
|
||||||
|
|
@ -174,9 +216,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st
|
||||||
})
|
})
|
||||||
|
|
||||||
// Second attempt: fall back to plain text message
|
// Second attempt: fall back to plain text message
|
||||||
textErr := c.sendText(ctx, msg.ChatID, msg.Content)
|
msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent)
|
||||||
if textErr == nil {
|
if textErr == nil {
|
||||||
return nil, nil
|
if isToolFeedback {
|
||||||
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
||||||
|
} else {
|
||||||
|
c.ClearToolFeedbackMessage(msg.ChatID)
|
||||||
|
}
|
||||||
|
return []string{msgID}, nil
|
||||||
}
|
}
|
||||||
// If text also fails, return the text error
|
// If text also fails, return the text error
|
||||||
return nil, textErr
|
return nil, textErr
|
||||||
|
|
@ -210,6 +257,23 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteMessage implements channels.MessageDeleter.
|
||||||
|
func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error {
|
||||||
|
req := larkim.NewDeleteMessageReqBuilder().
|
||||||
|
MessageId(messageID).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := c.client.Im.V1.Message.Delete(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu delete: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
c.invalidateTokenOnAuthError(resp.Code)
|
||||||
|
return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendPlaceholder implements channels.PlaceholderCapable.
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
// Sends an interactive card with placeholder text and returns its message ID.
|
// Sends an interactive card with placeholder text and returns its message ID.
|
||||||
func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
|
|
@ -251,6 +315,67 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return c.progress.Current(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.progress.Record(chatID, messageID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.progress.Clear(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
|
||||||
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(chatID)
|
||||||
|
_ = c.DeleteMessage(ctx, chatID, msgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
chatID string,
|
||||||
|
content string,
|
||||||
|
editFn func(context.Context, string, string, string) error,
|
||||||
|
) ([]string, bool) {
|
||||||
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
||||||
|
if !ok || editFn == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err := editFn(ctx, chatID, msgID, content); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(chatID)
|
||||||
|
return []string{msgID}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) {
|
||||||
|
if outboundMessageIsToolFeedback(msg) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage)
|
||||||
|
}
|
||||||
|
|
||||||
// ReactToMessage implements channels.ReactionCapable.
|
// ReactToMessage implements channels.ReactionCapable.
|
||||||
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
||||||
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
|
|
@ -323,6 +448,7 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return nil, channels.ErrNotRunning
|
return nil, channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
c.DismissToolFeedbackMessage(ctx, msg.ChatID)
|
||||||
|
|
||||||
if msg.ChatID == "" {
|
if msg.ChatID == "" {
|
||||||
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
|
|
@ -801,7 +927,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendCard sends an interactive card message to a chat.
|
// sendCard sends an interactive card message to a chat.
|
||||||
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error {
|
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) {
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
|
|
@ -813,23 +939,26 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("feishu send card: %w", channels.ErrTemporary)
|
return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !resp.Success() {
|
if !resp.Success() {
|
||||||
c.invalidateTokenOnAuthError(resp.Code)
|
c.invalidateTokenOnAuthError(resp.Code)
|
||||||
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("feishu", "Feishu card message sent", map[string]any{
|
logger.DebugCF("feishu", "Feishu card message sent", map[string]any{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
if resp.Data != nil && resp.Data.MessageId != nil {
|
||||||
|
return *resp.Data.MessageId, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendText sends a plain text message to a chat (fallback when card fails).
|
// sendText sends a plain text message to a chat (fallback when card fails).
|
||||||
func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error {
|
func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) {
|
||||||
content, _ := json.Marshal(map[string]string{"text": text})
|
content, _ := json.Marshal(map[string]string{"text": text})
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReqBuilder().
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
|
|
@ -843,18 +972,21 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("feishu send text: %w", channels.ErrTemporary)
|
return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !resp.Success() {
|
if !resp.Success() {
|
||||||
return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{
|
logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
if resp.Data != nil && resp.Data.MessageId != nil {
|
||||||
|
return *resp.Data.MessageId, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendImage uploads an image and sends it as a message.
|
// sendImage uploads an image and sends it as a message.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@
|
||||||
package feishu
|
package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -279,3 +283,56 @@ func TestExtractFeishuSenderID(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{
|
||||||
|
progress: channels.NewToolFeedbackAnimator(nil),
|
||||||
|
}
|
||||||
|
ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`")
|
||||||
|
|
||||||
|
msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage(
|
||||||
|
context.Background(),
|
||||||
|
"chat-1",
|
||||||
|
"final reply",
|
||||||
|
func(_ context.Context, chatID, messageID, content string) error {
|
||||||
|
if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" {
|
||||||
|
t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message")
|
||||||
|
}
|
||||||
|
if len(msgIDs) != 1 || msgIDs[0] != "msg-1" {
|
||||||
|
t.Fatalf("unexpected msgIDs: %v", msgIDs)
|
||||||
|
}
|
||||||
|
if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok {
|
||||||
|
t.Fatal("expected tracked tool feedback to be cleared after successful edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) {
|
||||||
|
ch := &FeishuChannel{
|
||||||
|
progress: channels.NewToolFeedbackAnimator(nil),
|
||||||
|
}
|
||||||
|
ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`")
|
||||||
|
|
||||||
|
msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage(
|
||||||
|
context.Background(),
|
||||||
|
"chat-1",
|
||||||
|
"final reply",
|
||||||
|
func(context.Context, string, string, string) error {
|
||||||
|
return errors.New("edit failed")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if handled {
|
||||||
|
t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure")
|
||||||
|
}
|
||||||
|
if len(msgIDs) != 0 {
|
||||||
|
t.Fatalf("unexpected msgIDs: %v", msgIDs)
|
||||||
|
}
|
||||||
|
if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" {
|
||||||
|
t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -96,6 +97,19 @@ type Manager struct {
|
||||||
channelHashes map[string]string // channel name → config hash
|
channelHashes map[string]string // channel name → config hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type toolFeedbackMessageTracker interface {
|
||||||
|
RecordToolFeedbackMessage(chatID, messageID, content string)
|
||||||
|
ClearToolFeedbackMessage(chatID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolFeedbackMessageCleaner interface {
|
||||||
|
DismissToolFeedbackMessage(ctx context.Context, chatID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolFeedbackMessageFinalizer interface {
|
||||||
|
FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
type asyncTask struct {
|
type asyncTask struct {
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +122,13 @@ func outboundMessageChatID(msg bus.OutboundMessage) string {
|
||||||
return msg.ChatID
|
return msg.ChatID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
|
}
|
||||||
|
|
||||||
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
|
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
|
||||||
return msg.Context.Channel
|
return msg.Context.Channel
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +217,19 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !outboundMessageIsToolFeedback(msg) {
|
||||||
|
if finalizer, ok := ch.(toolFeedbackMessageFinalizer); ok {
|
||||||
|
if msgIDs, handled := finalizer.FinalizeToolFeedbackMessage(ctx, msg); handled {
|
||||||
|
return msgIDs, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok {
|
||||||
|
cleaner.DismissToolFeedbackMessage(ctx, chatID)
|
||||||
|
} else if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
|
||||||
|
tracker.ClearToolFeedbackMessage(chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. If a stream already finalized this message, delete the placeholder and skip send
|
// 3. If a stream already finalized this message, delete the placeholder and skip send
|
||||||
if _, loaded := m.streamActive.LoadAndDelete(key); loaded {
|
if _, loaded := m.streamActive.LoadAndDelete(key); loaded {
|
||||||
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||||
|
|
@ -215,7 +249,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
||||||
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||||
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
||||||
if editor, ok := ch.(MessageEditor); ok {
|
if editor, ok := ch.(MessageEditor); ok {
|
||||||
if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil {
|
content := msg.Content
|
||||||
|
if outboundMessageIsToolFeedback(msg) {
|
||||||
|
content = InitialAnimatedToolFeedbackContent(msg.Content)
|
||||||
|
}
|
||||||
|
if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil {
|
||||||
|
if tracker, ok := ch.(toolFeedbackMessageTracker); ok && outboundMessageIsToolFeedback(msg) {
|
||||||
|
tracker.RecordToolFeedbackMessage(chatID, entry.id, msg.Content)
|
||||||
|
}
|
||||||
return []string{entry.id}, true
|
return []string{entry.id}, true
|
||||||
}
|
}
|
||||||
// edit failed → fall through to normal Send
|
// edit failed → fall through to normal Send
|
||||||
|
|
@ -234,6 +275,12 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun
|
||||||
chatID := outboundMediaChatID(msg)
|
chatID := outboundMediaChatID(msg)
|
||||||
key := name + ":" + chatID
|
key := name + ":" + chatID
|
||||||
|
|
||||||
|
if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok {
|
||||||
|
cleaner.DismissToolFeedbackMessage(ctx, chatID)
|
||||||
|
} else if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
|
||||||
|
tracker.ClearToolFeedbackMessage(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Stop typing
|
// 1. Stop typing
|
||||||
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
||||||
if entry, ok := v.(typingEntry); ok {
|
if entry, ok := v.(typingEntry); ok {
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,9 @@ func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM
|
||||||
|
|
||||||
type mockDeletingMediaChannel struct {
|
type mockDeletingMediaChannel struct {
|
||||||
mockMediaChannel
|
mockMediaChannel
|
||||||
deleteCalls int
|
deleteCalls int
|
||||||
lastDeleted struct {
|
dismissedChatID string
|
||||||
|
lastDeleted struct {
|
||||||
chatID string
|
chatID string
|
||||||
messageID string
|
messageID string
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +95,10 @@ func (m *mockDeletingMediaChannel) DeleteMessage(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) {
|
||||||
|
m.dismissedChatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
// newTestManager creates a minimal Manager suitable for unit tests.
|
// newTestManager creates a minimal Manager suitable for unit tests.
|
||||||
func newTestManager() *Manager {
|
func newTestManager() *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
|
|
@ -715,13 +720,41 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) {
|
||||||
// mockMessageEditor is a channel that supports MessageEditor.
|
// mockMessageEditor is a channel that supports MessageEditor.
|
||||||
type mockMessageEditor struct {
|
type mockMessageEditor struct {
|
||||||
mockChannel
|
mockChannel
|
||||||
editFn func(ctx context.Context, chatID, messageID, content string) error
|
editFn func(ctx context.Context, chatID, messageID, content string) error
|
||||||
|
finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool)
|
||||||
|
recordedChatID string
|
||||||
|
recordedMessageID string
|
||||||
|
clearedChatID string
|
||||||
|
dismissedChatID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||||
return m.editFn(ctx, chatID, messageID, content)
|
return m.editFn(ctx, chatID, messageID, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, _ string) {
|
||||||
|
m.recordedChatID = chatID
|
||||||
|
m.recordedMessageID = messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) {
|
||||||
|
m.clearedChatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) {
|
||||||
|
m.dismissedChatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMessageEditor) FinalizeToolFeedbackMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
msg bus.OutboundMessage,
|
||||||
|
) ([]string, bool) {
|
||||||
|
if m.finalizeFn == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return m.finalizeFn(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
|
func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
var sendCalled bool
|
var sendCalled bool
|
||||||
|
|
@ -766,6 +799,114 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
||||||
|
if chatID != "123" || messageID != "456" || content != "hello" {
|
||||||
|
t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
msg := testOutboundMessage(bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "hello",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Raw: map[string]string{
|
||||||
|
"message_kind": "tool_feedback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
if !edited {
|
||||||
|
t.Fatal("expected preSend to edit placeholder")
|
||||||
|
}
|
||||||
|
if ch.recordedChatID != "123" || ch.recordedMessageID != "456" {
|
||||||
|
t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_NonToolFeedbackDismissesTrackedMessage(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
ch := &mockMessageEditor{}
|
||||||
|
|
||||||
|
msg := testOutboundMessage(bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "final reply",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
if edited {
|
||||||
|
t.Fatal("expected preSend to fall through when no placeholder exists")
|
||||||
|
}
|
||||||
|
if ch.dismissedChatID != "123" {
|
||||||
|
t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_NonToolFeedbackFinalizerHandlesMessage(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) {
|
||||||
|
if msg.ChatID != "123" || msg.Content != "final reply" {
|
||||||
|
t.Fatalf("unexpected finalize msg: %+v", msg)
|
||||||
|
}
|
||||||
|
return []string{"tool-msg-1"}, true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := testOutboundMessage(bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "final reply",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgIDs, handled := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("expected preSend to be handled by tool feedback finalizer")
|
||||||
|
}
|
||||||
|
if len(msgIDs) != 1 || msgIDs[0] != "tool-msg-1" {
|
||||||
|
t.Fatalf("unexpected msgIDs: %v", msgIDs)
|
||||||
|
}
|
||||||
|
if ch.dismissedChatID != "" {
|
||||||
|
t.Fatalf("expected no dismiss when finalizer handled message, got %q", ch.dismissedChatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSendMedia_DismissesTrackedMessage(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
ch := &mockDeletingMediaChannel{}
|
||||||
|
|
||||||
|
m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{
|
||||||
|
ChatID: "123",
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "123",
|
||||||
|
},
|
||||||
|
}, ch)
|
||||||
|
|
||||||
|
if ch.dismissedChatID != "123" {
|
||||||
|
t.Fatalf("expected tracked tool feedback to be dismissed for media chat 123, got %q", ch.dismissedChatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
|
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,14 @@ var (
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
bh *th.BotHandler
|
bh *th.BotHandler
|
||||||
bc *config.Channel
|
bc *config.Channel
|
||||||
chatIDs map[string]int64
|
chatIDs map[string]int64
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
tgCfg *config.TelegramSettings
|
tgCfg *config.TelegramSettings
|
||||||
|
progress *channels.ToolFeedbackAnimator
|
||||||
|
|
||||||
registerFunc func(context.Context, []commands.Definition) error
|
registerFunc func(context.Context, []commands.Definition) error
|
||||||
commandRegCancel context.CancelFunc
|
commandRegCancel context.CancelFunc
|
||||||
|
|
@ -104,13 +105,15 @@ func NewTelegramChannel(
|
||||||
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
||||||
return &TelegramChannel{
|
ch := &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
bot: bot,
|
bot: bot,
|
||||||
bc: bc,
|
bc: bc,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
tgCfg: telegramCfg,
|
tgCfg: telegramCfg,
|
||||||
}, nil
|
}
|
||||||
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) Start(ctx context.Context) error {
|
func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
|
|
@ -168,6 +171,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
if c.progress != nil {
|
||||||
|
c.progress.StopAll()
|
||||||
|
}
|
||||||
if c.commandRegCancel != nil {
|
if c.commandRegCancel != nil {
|
||||||
c.commandRegCancel()
|
c.commandRegCancel()
|
||||||
}
|
}
|
||||||
|
|
@ -191,12 +197,29 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||||
|
if isToolFeedback {
|
||||||
|
animatedContent := channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
||||||
|
if msgID, ok := c.currentToolFeedbackMessage(msg.ChatID); ok {
|
||||||
|
if err := c.EditMessage(ctx, msg.ChatID, msgID, animatedContent); err == nil {
|
||||||
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
||||||
|
return []string{msgID}, nil
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(msg.ChatID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.DismissToolFeedbackMessage(ctx, msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
||||||
// so msg.Content is guaranteed to be within that limit. We still need to
|
// so msg.Content is guaranteed to be within that limit. We still need to
|
||||||
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
||||||
replyToID := msg.ReplyToMessageID
|
replyToID := msg.ReplyToMessageID
|
||||||
var messageIDs []string
|
var messageIDs []string
|
||||||
queue := []string{msg.Content}
|
queue := []string{msg.Content}
|
||||||
|
if isToolFeedback {
|
||||||
|
queue = []string{channels.InitialAnimatedToolFeedbackContent(msg.Content)}
|
||||||
|
}
|
||||||
for len(queue) > 0 {
|
for len(queue) > 0 {
|
||||||
chunk := queue[0]
|
chunk := queue[0]
|
||||||
queue = queue[1:]
|
queue = queue[1:]
|
||||||
|
|
@ -270,6 +293,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
||||||
replyToID = ""
|
replyToID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isToolFeedback && len(messageIDs) > 0 {
|
||||||
|
c.RecordToolFeedbackMessage(msg.ChatID, messageIDs[0], msg.Content)
|
||||||
|
} else if !isToolFeedback {
|
||||||
|
c.ClearToolFeedbackMessage(msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
return messageIDs, nil
|
return messageIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,6 +466,43 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
||||||
|
if len(msg.Context.Raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return c.progress.Current(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.progress.Record(chatID, messageID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) {
|
||||||
|
if c.progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.progress.Clear(chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
|
||||||
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ClearToolFeedbackMessage(chatID)
|
||||||
|
_ = c.DeleteMessage(ctx, chatID, msgID)
|
||||||
|
}
|
||||||
|
|
||||||
// SendPlaceholder implements channels.PlaceholderCapable.
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
||||||
// edited to the actual response via EditMessage (channels.MessageEditor).
|
// edited to the actual response via EditMessage (channels.MessageEditor).
|
||||||
|
|
@ -468,6 +534,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return nil, channels.ErrNotRunning
|
return nil, channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
c.DismissToolFeedbackMessage(ctx, msg.ChatID)
|
||||||
|
|
||||||
chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
|
chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -98,13 +98,24 @@ func (s *multipartRecordingConstructor) MultipartRequest(
|
||||||
|
|
||||||
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
||||||
func successResponse(t *testing.T) *ta.Response {
|
func successResponse(t *testing.T) *ta.Response {
|
||||||
|
return successResponseWithMessageID(t, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
msg := &telego.Message{MessageID: 1}
|
msg := &telego.Message{MessageID: messageID}
|
||||||
b, err := json.Marshal(msg)
|
b, err := json.Marshal(msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return &ta.Response{Ok: true, Result: b}
|
return &ta.Response{Ok: true, Result: b}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func successBoolResponse(t *testing.T) *ta.Response {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return &ta.Response{Ok: true, Result: b}
|
||||||
|
}
|
||||||
|
|
||||||
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
|
func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := json.Marshal(user)
|
b, err := json.Marshal(user)
|
||||||
|
|
@ -142,6 +153,7 @@ func newTestChannelWithConstructor(
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true},
|
||||||
tgCfg: &config.TelegramSettings{},
|
tgCfg: &config.TelegramSettings{},
|
||||||
|
progress: channels.NewToolFeedbackAnimator(nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,6 +278,37 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
|
||||||
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
|
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(url, "deleteMessage"):
|
||||||
|
return successBoolResponse(t), nil
|
||||||
|
case strings.Contains(url, "sendMessage"):
|
||||||
|
return successResponseWithMessageID(t, 2), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`")
|
||||||
|
|
||||||
|
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "final reply",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"2"}, ids)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "deleteMessage")
|
||||||
|
assert.Contains(t, caller.calls[1].URL, "sendMessage")
|
||||||
|
_, ok := ch.currentToolFeedbackMessage("12345")
|
||||||
|
assert.False(t, ok, "tracked tool feedback should be cleared after final reply")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
||||||
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
||||||
// they reach Send(). A message at exactly 4000 chars should go through
|
// they reach Send(). A message at exactly 4000 chars should go through
|
||||||
|
|
|
||||||
189
pkg/channels/tool_feedback_animator.go
Normal file
189
pkg/channels/tool_feedback_animator.go
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolFeedbackAnimationInterval = 3 * time.Second
|
||||||
|
|
||||||
|
const initialToolFeedbackAnimationFrame = ""
|
||||||
|
|
||||||
|
var toolFeedbackAnimationFrames = []string{"..", "."}
|
||||||
|
|
||||||
|
type toolFeedbackAnimationState struct {
|
||||||
|
messageID string
|
||||||
|
baseContent string
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolFeedbackAnimator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
editFn func(ctx context.Context, chatID, messageID, content string) error
|
||||||
|
entries map[string]*toolFeedbackAnimationState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewToolFeedbackAnimator(
|
||||||
|
editFn func(ctx context.Context, chatID, messageID, content string) error,
|
||||||
|
) *ToolFeedbackAnimator {
|
||||||
|
return &ToolFeedbackAnimator{
|
||||||
|
editFn: editFn,
|
||||||
|
entries: make(map[string]*toolFeedbackAnimationState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) {
|
||||||
|
if a == nil || strings.TrimSpace(chatID) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
entry, ok := a.entries[chatID]
|
||||||
|
if !ok || strings.TrimSpace(entry.messageID) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return entry.messageID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) {
|
||||||
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatID = strings.TrimSpace(chatID)
|
||||||
|
messageID = strings.TrimSpace(messageID)
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if chatID == "" || messageID == "" || content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &toolFeedbackAnimationState{
|
||||||
|
messageID: messageID,
|
||||||
|
baseContent: content,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous *toolFeedbackAnimationState
|
||||||
|
a.mu.Lock()
|
||||||
|
if old, ok := a.entries[chatID]; ok {
|
||||||
|
previous = old
|
||||||
|
}
|
||||||
|
a.entries[chatID] = entry
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
stopToolFeedbackAnimation(previous)
|
||||||
|
go a.run(chatID, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ToolFeedbackAnimator) Clear(chatID string) {
|
||||||
|
if a == nil || strings.TrimSpace(chatID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var entry *toolFeedbackAnimationState
|
||||||
|
a.mu.Lock()
|
||||||
|
if old, ok := a.entries[chatID]; ok {
|
||||||
|
entry = old
|
||||||
|
delete(a.entries, chatID)
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
stopToolFeedbackAnimation(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ToolFeedbackAnimator) StopAll() {
|
||||||
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
entries := make([]*toolFeedbackAnimationState, 0, len(a.entries))
|
||||||
|
for chatID, entry := range a.entries {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
delete(a.entries, chatID)
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
stopToolFeedbackAnimation(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
|
||||||
|
defer close(entry.done)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(toolFeedbackAnimationInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
frameIdx := 1
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-entry.stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if a.editFn == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)]
|
||||||
|
content := formatAnimatedToolFeedbackContent(entry.baseContent, frame)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
_ = a.editFn(ctx, chatID, entry.messageID, content)
|
||||||
|
cancel()
|
||||||
|
frameIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitialAnimatedToolFeedbackContent(baseContent string) string {
|
||||||
|
return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAnimatedToolFeedbackContent(baseContent, frame string) string {
|
||||||
|
baseContent = strings.TrimSpace(baseContent)
|
||||||
|
frame = strings.TrimSpace(frame)
|
||||||
|
if baseContent == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if frame == "" {
|
||||||
|
return baseContent
|
||||||
|
}
|
||||||
|
lineBreak := strings.IndexByte(baseContent, '\n')
|
||||||
|
if lineBreak < 0 {
|
||||||
|
return appendToolFeedbackFrame(baseContent, frame)
|
||||||
|
}
|
||||||
|
return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendToolFeedbackFrame(firstLine, frame string) string {
|
||||||
|
firstLine = strings.TrimSpace(firstLine)
|
||||||
|
frame = strings.TrimSpace(frame)
|
||||||
|
if firstLine == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if frame == "" {
|
||||||
|
return firstLine
|
||||||
|
}
|
||||||
|
|
||||||
|
openTick := strings.IndexByte(firstLine, '`')
|
||||||
|
if openTick >= 0 {
|
||||||
|
if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 {
|
||||||
|
closeTick := openTick + 1 + closeOffset
|
||||||
|
return firstLine[:closeTick] + frame + firstLine[closeTick:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstLine + frame
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
|
||||||
|
if entry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-entry.stop:
|
||||||
|
default:
|
||||||
|
close(entry.stop)
|
||||||
|
}
|
||||||
|
<-entry.done
|
||||||
|
}
|
||||||
44
pkg/channels/tool_feedback_animator_test.go
Normal file
44
pkg/channels/tool_feedback_animator_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFormatAnimatedToolFeedbackContent(t *testing.T) {
|
||||||
|
got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..")
|
||||||
|
want := "🔧 `read_filerunning..`\nReading config file"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialAnimatedToolFeedbackContent(t *testing.T) {
|
||||||
|
got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command")
|
||||||
|
want := "🔧 `exec`\nRunning command"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) {
|
||||||
|
got := formatAnimatedToolFeedbackContent("hello", "running..")
|
||||||
|
want := "hellorunning.."
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) {
|
||||||
|
animator := NewToolFeedbackAnimator(nil)
|
||||||
|
animator.Record("chat-1", "msg-1", "🔧 `read_file`")
|
||||||
|
|
||||||
|
msgID, ok := animator.Current("chat-1")
|
||||||
|
if !ok || msgID != "msg-1" {
|
||||||
|
t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
animator.Clear("chat-1")
|
||||||
|
|
||||||
|
msgID, ok = animator.Current("chat-1")
|
||||||
|
if ok || msgID != "" {
|
||||||
|
t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -462,11 +462,13 @@ func defaultChannels() ChannelsConfig {
|
||||||
"use_markdown_v2": false,
|
"use_markdown_v2": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"feishu": map[string]any{},
|
"feishu": map[string]any{},
|
||||||
"discord": map[string]any{},
|
"discord": map[string]any{
|
||||||
"maixcam": map[string]any{
|
"placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}},
|
||||||
"settings": map[string]any{"host": "0.0.0.0", "port": 18790},
|
},
|
||||||
},
|
"maixcam": map[string]any{
|
||||||
|
"settings": map[string]any{"host": "0.0.0.0", "port": 18790},
|
||||||
|
},
|
||||||
"qq": map[string]any{
|
"qq": map[string]any{
|
||||||
"settings": map[string]any{"max_message_length": 2000},
|
"settings": map[string]any{"max_message_length": 2000},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// FormatToolFeedbackMessage renders the tool name and arguments preview in the
|
// FormatToolFeedbackMessage renders the model-provided explanation for why a
|
||||||
// same markdown shape used by live tool feedback and session reconstruction.
|
// tool is being executed. When the model does not provide one, it keeps only
|
||||||
func FormatToolFeedbackMessage(toolName, argsPreview string) string {
|
// the tool line and does not expose raw arguments or fallback text.
|
||||||
return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview)
|
func FormatToolFeedbackMessage(toolName, explanation string) string {
|
||||||
|
toolName = strings.TrimSpace(toolName)
|
||||||
|
explanation = strings.TrimSpace(explanation)
|
||||||
|
|
||||||
|
if toolName == "" {
|
||||||
|
return explanation
|
||||||
|
}
|
||||||
|
if explanation == "" {
|
||||||
|
return fmt.Sprintf("\U0001f527 `%s`", toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,24 @@ package utils
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestFormatToolFeedbackMessage(t *testing.T) {
|
func TestFormatToolFeedbackMessage(t *testing.T) {
|
||||||
got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}")
|
got := FormatToolFeedbackMessage("read_file", "I will read README.md first to confirm the current project structure.")
|
||||||
want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```"
|
want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure."
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessage("read_file", "")
|
||||||
|
want := "\U0001f527 `read_file`"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
|
||||||
|
got := FormatToolFeedbackMessage("", "Continue drafting the final response.")
|
||||||
|
want := "Continue drafting the final response."
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -551,7 +551,6 @@ func visibleAssistantToolSummaryMessages(
|
||||||
if argsPreview == "" {
|
if argsPreview == "" {
|
||||||
argsPreview = "{}"
|
argsPreview = "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, sessionChatMessage{
|
messages = append(messages, sessionChatMessage{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)),
|
Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)),
|
||||||
|
|
|
||||||
|
|
@ -682,6 +682,9 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
||||||
if strings.Contains(resp.Messages[1].Content, argsJSON) {
|
if strings.Contains(resp.Messages[1].Content, argsJSON) {
|
||||||
t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
|
t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||||
|
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) {
|
func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue