package agent import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "github.com/ituoke/ituoke/internal/mcpclient" "github.com/modelcontextprotocol/go-sdk/mcp" ) func toolTestService(t *testing.T, handler func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, map[string]any, error)) (*mcpclient.Service, string) { t.Helper() server := mcp.NewServer(&mcp.Implementation{Name: "phone-test", Version: "1"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "phone_action", Description: "Test phone action"}, handler) httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)) t.Cleanup(httpServer.Close) // Keep test configuration isolated without touching the user's service file. t.Setenv("APPDATA", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("HOME", t.TempDir()) service := mcpclient.NewService() view, err := service.SaveServer(mcpclient.ServerInput{Config: mcpclient.ServerConfig{Name: "Phone", Enabled: true, Transport: "http", URL: httpServer.URL, TimeoutSeconds: 3}}) if err != nil { t.Fatal(err) } return service, view.Config.ID } func TestAgentStreamsToolCallsAndFeedsResultsBack(t *testing.T) { var executions atomic.Int32 mcpService, _ := toolTestService(t, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, map[string]any, error) { executions.Add(1) if args["action"] != "open_settings" { t.Errorf("arguments lost: %v", args) } return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "settings_visible"}}}, nil, nil }) var rounds atomic.Int32 model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Tools []struct { Function struct { Name string `json:"name"` } `json:"function"` } `json:"tools"` Messages []modelMessage `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Error(err) return } w.Header().Set("Content-Type", "text/event-stream") if rounds.Add(1) == 1 { if len(body.Tools) != 1 { t.Errorf("missing tools: %+v", body.Tools) return } name := body.Tools[0].Function.Name fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"先打开设置。\"}}]}\n\n") fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":%q,\"arguments\":\"{\\\"action\\\":\"}}]}}]}\n\n", name) fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"open_settings\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n") } else { last := body.Messages[len(body.Messages)-1] previous := body.Messages[len(body.Messages)-2] if previous.ReasoningContent != "先打开设置。" { t.Errorf("tool-round reasoning was not passed back: %+v", previous) } if last.Role != "tool" || last.ToolCallID != "call-1" || !strings.Contains(fmt.Sprint(last.Content), "settings_visible") { t.Errorf("tool result not returned: %+v", last) } fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"页面验证完成。\",\"content\":\"设置已打开并验证。\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") } })) defer model.Close() s, _ := testService(t) s.mcp = mcpService if _, err := s.SaveModelConfig(ModelConfigInput{Config: testConfig(model.URL), APIKey: "test-key"}); err != nil { t.Fatal(err) } var events []ToolExecution var text strings.Builder var thought strings.Builder s.emitDelta = func(delta ChatDelta) { if delta.Tool != nil { events = append(events, *delta.Tool) } text.WriteString(delta.Delta) thought.WriteString(delta.ReasoningDelta) } reply, err := s.ChatStream(context.Background(), "tool-test", "chat", "打开系统设置") if err != nil || reply["content"] != "设置已打开并验证。" || text.String() != reply["content"] { t.Fatalf("reply: %v %v", reply, err) } if executions.Load() != 1 || len(events) != 2 || events[0].Status != "running" || events[1].Status != "complete" { t.Fatalf("execution: %d %+v", executions.Load(), events) } if reply["reasoning"] != "先打开设置。\n\n页面验证完成。" || thought.String() != reply["reasoning"] { t.Fatalf("tool-round reasoning lost: %q %v", thought.String(), reply) } } func TestAgentCancellationStopsLaterTools(t *testing.T) { var calls atomic.Int32 ctx, cancel := context.WithCancel(context.Background()) defer cancel() mcpService, _ := toolTestService(t, func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, map[string]any, error) { calls.Add(1) return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, nil, nil }) model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Tools []struct { Function struct { Name string `json:"name"` } `json:"function"` } `json:"tools"` } json.NewDecoder(r.Body).Decode(&body) w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"one\",\"type\":\"function\",\"function\":{\"name\":%q,\"arguments\":\"{}\"}},{\"index\":1,\"id\":\"two\",\"type\":\"function\",\"function\":{\"name\":%q,\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n", body.Tools[0].Function.Name, body.Tools[0].Function.Name) })) defer model.Close() s, _ := testService(t) s.mcp = mcpService s.SaveModelConfig(ModelConfigInput{Config: testConfig(model.URL), APIKey: "key"}) s.emitDelta = func(delta ChatDelta) { if delta.Tool != nil && delta.Tool.Status == "complete" { cancel() } } _, err := s.ChatStream(ctx, "cancel-test", "chat", "test") if !errors.Is(err, context.Canceled) || calls.Load() != 1 || len(s.history["chat"]) != 0 { t.Fatalf("cancellation: %d %v", calls.Load(), err) } } func TestIncompleteToolCallDoesNotExecute(t *testing.T) { for _, stream := range []string{ `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"x","type":"function","function":{"name":"action","arguments":"{"}}]},"finish_reason":"length"}]}` + "\n\n", `data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"x","type":"function","function":{"name":"action","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}` + "\n\n", } { if _, err := readToolStream(strings.NewReader(stream), "", nil); err == nil { t.Fatal("accepted incomplete tool call") } } } func TestAgentNonStreamReturnsToolFailureToModel(t *testing.T) { mcpService, _ := toolTestService(t, func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, map[string]any, error) { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "device disconnected"}}}, nil, nil }) var rounds atomic.Int32 model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Tools []struct { Function struct { Name string `json:"name"` } `json:"function"` } `json:"tools"` Messages []modelMessage `json:"messages"` } json.NewDecoder(r.Body).Decode(&body) w.Header().Set("Content-Type", "application/json") if rounds.Add(1) == 1 { fmt.Fprintf(w, `{"choices":[{"message":{"content":null,"tool_calls":[{"id":"one","type":"function","function":{"name":%q,"arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`, body.Tools[0].Function.Name) } else { last := body.Messages[len(body.Messages)-1] if last.Role != "tool" || !strings.Contains(fmt.Sprint(last.Content), "device disconnected") || !strings.Contains(fmt.Sprint(last.Content), "失败") { t.Errorf("tool failure lost: %+v", last) } fmt.Fprint(w, `{"choices":[{"message":{"content":"设备断开,操作没有完成。"},"finish_reason":"stop"}]}`) } })) defer model.Close() s, _ := testService(t) s.mcp = mcpService s.SaveModelConfig(ModelConfigInput{Config: testConfig(model.URL), APIKey: "key"}) result, err := s.Chat("test", "test") if err != nil || result["content"] != "设备断开,操作没有完成。" { t.Fatalf("non-stream recovery: %v %v", result, err) } } func TestToolImagesStayOutOfTextAndHistory(t *testing.T) { text, images := toolOutput(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "screen"}, &mcp.ImageContent{MIMEType: "image/png", Data: []byte("image-bytes")}}}) if strings.Contains(text, "image-bytes") || len(images) != 1 || !strings.Contains(text, "1 张图片") { t.Fatalf("image adapter: %s %v", text, images) } encoded, _ := json.Marshal(images) if !strings.Contains(string(encoded), "data:image/png;base64,") { t.Fatal("missing model image input") } }