This commit is contained in:
lxowalle 2026-04-17 17:45:57 +08:00
parent cea0fa35db
commit a188e7463c
8 changed files with 49 additions and 146 deletions

View file

@ -3,6 +3,7 @@ package auth
import ( import (
"bytes" "bytes"
"context" "context"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -19,6 +20,19 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(handler)
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
server.Listener = listener
server.Start()
t.Cleanup(server.Close)
return server
}
func TestNewWeComCommand(t *testing.T) { func TestNewWeComCommand(t *testing.T) {
cmd := newWeComCommand() cmd := newWeComCommand()
@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) {
} }
func TestFetchWeComQRCode(t *testing.T) { func TestFetchWeComQRCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/generate", r.URL.Path) assert.Equal(t, "/generate", r.URL.Path)
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source")) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID")) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
})) }))
defer server.Close()
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
HTTPClient: server.Client(), HTTPClient: server.Client(),
@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) {
func TestPollWeComQRCodeResult(t *testing.T) { func TestPollWeComQRCodeResult(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1) call := calls.Add(1)
assert.Equal(t, "/query", r.URL.Path) assert.Equal(t, "/query", r.URL.Path)
assert.Equal(t, "scode-1", r.URL.Query().Get("scode")) assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) {
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
} }
})) }))
defer server.Close()
var output bytes.Buffer var output bytes.Buffer
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{

View file

@ -1794,43 +1794,6 @@ func (m *toolFeedbackReasoningProvider) GetDefaultModel() string {
return "tool-feedback-reasoning-model" return "tool-feedback-reasoning-model"
} }
type toolFeedbackExtraContentProvider struct {
filePath string
calls int
}
func (m *toolFeedbackExtraContentProvider) 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{
ToolCalls: []providers.ToolCall{{
ID: "call_explicit_read_file",
Type: "function",
Name: "read_file",
Arguments: map[string]any{"path": m.filePath},
ExtraContent: &providers.ExtraContent{
ToolFeedbackExplanation: "Read README.md first to confirm the current project structure.",
},
}},
}, nil
}
return &providers.LLMResponse{
Content: "DONE",
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *toolFeedbackExtraContentProvider) GetDefaultModel() string {
return "tool-feedback-extra-content-model"
}
func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) { func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) {
response := &providers.LLMResponse{ response := &providers.LLMResponse{
Content: "Read README.md first", Content: "Read README.md first",
@ -3934,8 +3897,14 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
} }
func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) { func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) {
assertToolFeedbackNotPublishedWhenDisabled(t, "discord")
}
func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) {
t.Helper()
tmpDir := t.TempDir() tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "tool-feedback-discord.txt") heartbeatFile := filepath.Join(tmpDir, "tool-feedback-"+channel+".txt")
if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
@ -3961,7 +3930,7 @@ func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *test
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "discord", Channel: channel,
SenderID: "user-1", SenderID: "user-1",
ChatID: "chat-1", ChatID: "chat-1",
Content: "check tool feedback", Content: "check tool feedback",
@ -3975,103 +3944,17 @@ func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *test
select { select {
case outbound := <-msgBus.OutboundChan(): case outbound := <-msgBus.OutboundChan():
t.Fatalf("expected no outbound tool feedback for discord when disabled, got %+v", outbound) t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound)
case <-time.After(200 * time.Millisecond): case <-time.After(200 * time.Millisecond):
} }
} }
func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) { func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) {
tmpDir := t.TempDir() assertToolFeedbackNotPublishedWhenDisabled(t, "telegram")
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) { func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) {
tmpDir := t.TempDir() assertToolFeedbackNotPublishedWhenDisabled(t, "feishu")
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) {

View file

@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"

View file

@ -7,9 +7,9 @@ import (
"errors" "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"
"github.com/sipeed/picoclaw/pkg/channels"
) )
func TestExtractContent(t *testing.T) { func TestExtractContent(t *testing.T) {

View file

@ -932,7 +932,10 @@ func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) {
}, ch) }, ch)
if ch.dismissedChatID != "" { if ch.dismissedChatID != "" {
t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel media send, got %q", ch.dismissedChatID) t.Fatalf(
"expected tracked tool feedback cleanup to be deferred to channel media send, got %q",
ch.dismissedChatID,
)
} }
} }

View file

@ -109,13 +109,6 @@ func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
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)

View file

@ -3,7 +3,10 @@ package utils
import "testing" import "testing"
func TestFormatToolFeedbackMessage(t *testing.T) { func TestFormatToolFeedbackMessage(t *testing.T) {
got := FormatToolFeedbackMessage("read_file", "I will read README.md first to confirm the current project structure.") got := FormatToolFeedbackMessage(
"read_file",
"I will read README.md first to confirm the current project structure.",
)
want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure." want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure."
if got != want { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
@ -27,7 +30,10 @@ func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
} }
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
got := FitToolFeedbackMessage("\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", 40) got := FitToolFeedbackMessage(
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
40,
)
want := "\U0001f527 `read_file`\nRead README.md first to..." want := "\U0001f527 `read_file`\nRead README.md first to..."
if got != want { if got != want {
t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)

View file

@ -700,7 +700,8 @@ func TestHandleGetSession_DoesNotExposeLegacyToolArgumentsWhenExplanationMissing
t.Fatalf("LoadConfig() error = %v", err) t.Fatalf("LoadConfig() error = %v", err)
} }
cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20
if err := config.SaveConfig(configPath, cfg); err != nil { err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err) t.Fatalf("SaveConfig() error = %v", err)
} }
@ -712,7 +713,11 @@ func TestHandleGetSession_DoesNotExposeLegacyToolArgumentsWhenExplanationMissing
argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args" sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args"
if err := store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}); err != nil { if err := store.AddFullMessage(
nil,
sessionKey,
providers.Message{Role: "user", Content: "check file"},
); err != nil {
t.Fatalf("AddFullMessage(user) error = %v", err) t.Fatalf("AddFullMessage(user) error = %v", err)
} }
if err := store.AddFullMessage(nil, sessionKey, providers.Message{ if err := store.AddFullMessage(nil, sessionKey, providers.Message{